Compare commits

..

82 Commits

Author SHA1 Message Date
Janez T
a55cfffa59 Redesign contact import screen 2026-03-15 21:02:17 +01:00
Janez T
2bb5b353bd Fix channel refresh telemetry how 2026-03-15 21:00:02 +01:00
Janez T
991421d097 Fix telemetry merge bug 2026-03-15 20:58:12 +01:00
Janez Troha
1dcd85f178 Merge pull request #26 from dz0ny/feat/sensor-telemetry-preview
Feat/sensor telemetry preview
2026-03-15 20:14:31 +01:00
Janez T
5d2f61749e Update sensor parsing layout 2026-03-15 20:14:01 +01:00
Janez T
69377d90a4 Update auto-refresh and UI 2026-03-15 11:15:19 +01:00
Janez T
3e559a4f4e Enforce theme colors on switches 2026-03-15 10:56:47 +01:00
Janez T
a124e3d9f2 Improve sensor settings UI 2026-03-15 10:48:48 +01:00
Janez T
3bf4971279 Update sensor preview interactions 2026-03-15 10:43:37 +01:00
Janez T
e355f28ca9 Update sensor customization view 2026-03-15 10:40:16 +01:00
Janez Troha
7a9be3eb76 Merge pull request #25 from dz0ny/feat/sensor-telemetry-preview
Feat/sensor telemetry preview
2026-03-15 10:34:36 +01:00
Janez T
650f3c66aa Show values in sensor fields 2026-03-15 10:22:53 +01:00
Janez T
df993cd176 Enhance sensor telemetry cards 2026-03-15 10:10:22 +01:00
Janez T
feb3145854 Add sensor contact preview sheet 2026-03-15 09:36:31 +01:00
Janez T
da40acf950 chore: Update Meshcore client pin
ref:
2026-03-15 09:31:18 +01:00
Janez T
66eb32bf44 Fix BLE contact add failure 2026-03-15 09:24:41 +01:00
Janez T
c2e0fa952e Fix contact add failure 2026-03-15 09:19:59 +01:00
Janez T
4557fb4b9e feat: Add Sensor telemetry alerts
ref:
2026-03-15 09:13:32 +01:00
Janez T
c05de2cf0a Update iOS project version 2026-03-14 21:42:05 +01:00
Janez Troha
c5145c2fca Merge pull request #24 from dz0ny/feat/update-changelog-highlights
Add weekly changelog highlights
2026-03-14 21:36:10 +01:00
Janez T
e502bc23b7 Update changelog to drop LPCNet 2026-03-14 21:35:57 +01:00
Janez T
62bf33c9d1 fix: Replace web unsafe hash
ref:
2026-03-14 21:34:57 +01:00
Janez T
7be1460ef6 Update changelog highlights 2026-03-14 21:32:41 +01:00
Janez T
30c273c656 Fix connection provider auto-add bug 2026-03-14 21:30:23 +01:00
Janez T
5712feba53 Reduce composer mention padding 2026-03-14 21:10:22 +01:00
Janez T
6de12225fc feat: Finish discovery flow
ref:
2026-03-14 21:04:23 +01:00
Janez T
6e9e9e397d Fix RX advert data parsing 2026-03-14 16:34:31 +01:00
Janez T
a3147e9d7c Summarize repo changes 2026-03-14 10:39:27 +01:00
Janez T
5e1912d9f2 Remove artificial UI delays 2026-03-14 10:24:50 +01:00
Janez T
b42328bd82 Ensure contact sync waits 2026-03-14 10:24:14 +01:00
Janez T
1b801a10ef Remove hex logs and keep path data 2026-03-14 10:07:11 +01:00
Janez T
06632ee32d Update log display handling 2026-03-14 10:01:17 +01:00
Janez T
e7a481f97b Warn channels for delayed log rx 2026-03-13 20:32:07 +01:00
Janez T
9f31f50e61 Warn channels about delayed message 2026-03-13 20:14:07 +01:00
Janez T
d4237b7569 Add channel echo warning tracking 2026-03-13 20:07:39 +01:00
Janez T
b35c11c804 fix: bump meshcore client
ref:
2026-03-13 20:02:13 +01:00
Janez T
be2cf53374 Investigate Command 0x1f timeout 2026-03-13 19:58:41 +01:00
Janez T
13a729492f fix: update meshcore client ref
ref:
2026-03-13 19:56:50 +01:00
Janez T
9d8594fe05 Fix channel message dedup logic 2026-03-13 19:39:26 +01:00
Janez T
249268dd00 Prevent duplicate channel messages 2026-03-13 19:39:03 +01:00
Janez T
63aa9619e6 Clear message input on send 2026-03-13 19:38:05 +01:00
Janez T
a7ad4bb8ec Clear message input after send 2026-03-13 19:36:20 +01:00
Janez T
636e114428 Fix channel syncing and logging 2026-03-13 19:27:14 +01:00
Janez T
d5684c51c7 Fix channel sync and logging 2026-03-13 19:24:42 +01:00
Janez T
ced39b6992 Fix channel sync errors 2026-03-13 19:15:41 +01:00
Janez T
083b715039 Add clearChannelMessages to Messages 2026-03-13 18:55:05 +01:00
Janez T
36584be0ab Fix channel deletion state 2026-03-13 18:49:54 +01:00
Janez T
dc19296804 Fix channel overwrite bug 2026-03-13 18:48:17 +01:00
Janez T
40072175e3 Prevent duplicate hash channels 2026-03-13 18:46:33 +01:00
Janez T
11f3bf9f97 Fix duplicate self messages 2026-03-13 18:44:17 +01:00
Janez T
24e715d01e Bump iOS project version 2026-03-13 14:18:27 +01:00
Janez T
6e17de3866 Add profile storage and sync 2026-03-13 10:34:52 +01:00
Janez T
90877a1891 Add bidirectional refresh physics 2026-03-13 10:31:37 +01:00
Janez T
77e43843bf Update contacts tab widgets 2026-03-13 10:25:23 +01:00
Janez T
1e70f52069 Allow overriding contact name 2026-03-13 10:16:04 +01:00
Janez T
5e404944e9 Investigate REQ PATH usage 2026-03-12 20:56:37 +01:00
Janez T
53daf2c9a7 Add packet filter stats and hex 2026-03-12 20:54:04 +01:00
Janez T
c36d63a0ad Add Flutter live traffic monitor 2026-03-12 20:19:24 +01:00
Janez T
69ec2dfdb5 Add live mesh traffic view 2026-03-12 20:12:25 +01:00
Janez T
d2e6b692f3 Fix voice quality regression 2026-03-12 19:38:41 +01:00
Janez T
baa1aa6edd Update contacts filter UI 2026-03-12 14:46:36 +01:00
Janez T
38cb3dc031 Fix retry flow and contact filters 2026-03-12 14:41:12 +01:00
Janez T
abb73a4c65 Fix retry flow and contact filters 2026-03-12 14:39:44 +01:00
Janez T
2d1e6c0ca4 Fix tracing path and display issues 2026-03-12 14:08:00 +01:00
Janez T
b38a8c55d6 Switch lpcnet plugin to git 2026-03-11 21:20:53 +01:00
Janez T
ca872f2049 Add LPCNet voice mode support 2026-03-11 21:16:08 +01:00
Janez T
1b6ff59af3 Add LPCNet voice mode option 2026-03-11 20:47:17 +01:00
Janez T
e31bfac2ac Retain rx path for adverts 2026-03-11 20:21:17 +01:00
Janez T
99bb446ee9 Update iOS Runner project version 2026-03-11 16:31:53 +01:00
Janez T
088f3d37ef Update Portuguese wizard text 2026-03-11 16:07:38 +01:00
Janez T
7f200e5334 Update translations for all locales 2026-03-11 16:07:09 +01:00
Janez T
ae16417886 Resolve localization and name bugs 2026-03-11 15:54:00 +01:00
Janez T
467371946d Add channel slot detection fix 2026-03-11 15:30:31 +01:00
Janez T
2d23328ad0 Fix empty channel slot handling 2026-03-11 15:10:57 +01:00
Janez T
0dfb84bf4e Fix onboarding layout issues 2026-03-11 15:08:04 +01:00
Janez T
193a193e8a Ensure constant iOS location updates 2026-03-10 19:56:24 +01:00
Janez T
c1d1ec7d0c Enable iOS background GPS updates 2026-03-10 19:50:02 +01:00
Janez T
b415b63b4b Add GPX import export for trails 2026-03-10 13:52:37 +01:00
Janez T
818b9dbfb2 Review MeshCore payload status 2026-03-10 11:05:54 +01:00
Janez T
8085b89c07 Add repeaters map menu entry 2026-03-10 10:38:59 +01:00
Janez T
86771a3c55 Add fullscreen repeater map 2026-03-10 10:25:43 +01:00
Janez T
4b668ac2e9 Fix channel list layout behavior 2026-03-10 09:46:38 +01:00
134 changed files with 27978 additions and 4561 deletions

1
.gitignore vendored
View File

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

13
CHANGELOG.md Normal file
View File

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

View File

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

View File

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

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.00024">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.511584"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.554978">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="95.467725"> <testcase classname="fastlane.lanes" name="2: build_app" time="100.17557">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="240.60994"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="264.49426">
<failure message="/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in &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> </testcase>

View File

@@ -1586,5 +1586,57 @@
"rawDump": "Rohdaten", "rawDump": "Rohdaten",
"cannotRetryMissingRecipient": "Wiederholung nicht möglich: Empfängerinformationen fehlen", "cannotRetryMissingRecipient": "Wiederholung nicht möglich: Empfängerinformationen fehlen",
"voiceUnavailable": "Sprachnachricht momentan nicht verfügbar", "voiceUnavailable": "Sprachnachricht momentan nicht verfügbar",
"requestingVoice": "Sprachnachricht wird angefordert" "requestingVoice": "Sprachnachricht wird angefordert",
"device": "Gerät",
"change": "Ändern",
"wizardOverviewDescription": "Diese App vereint MeshCore-Nachrichten, SAR-Feldupdates, Karten und Gerätetools an einem Ort.",
"wizardOverviewFeature1": "Sende Direkt-, Raum- und Kanalnachrichten aus dem Haupttab \"Nachrichten\".",
"wizardOverviewFeature2": "Teile SAR-Markierungen, Kartenzeichnungen, Sprachclips und Bilder über das Mesh.",
"wizardOverviewFeature3": "Verbinde dich per BLE oder TCP und verwalte dann das Begleitfunkgerät direkt in der App.",
"wizardMessagingTitle": "Nachrichten und Feldberichte",
"wizardMessagingDescription": "Nachrichten sind hier mehr als nur Klartext. Die App unterstützt bereits mehrere operative Datentypen und Übertragungsabläufe.",
"wizardMessagingFeature1": "Sende Direktnachrichten, Raumbeiträge und Kanalverkehr aus einem einzigen Editor.",
"wizardMessagingFeature2": "Erstelle SAR-Updates und wiederverwendbare SAR-Vorlagen für häufige Feldberichte.",
"wizardMessagingFeature3": "Übertrage Sprachsitzungen und Bilder mit Fortschritts- und Airtime-Anzeigen in der Oberfläche.",
"wizardConnectDeviceTitle": "Gerät verbinden",
"wizardConnectDeviceDescription": "Verbinde dein MeshCore-Funkgerät, wähle einen Namen und wende ein Funkprofil an, bevor du fortfährst.",
"wizardSetupBadge": "Einrichtung",
"wizardOverviewBadge": "Überblick",
"wizardConnectedToDevice": "Verbunden mit {deviceName}",
"wizardNoDeviceConnected": "Noch kein Gerät verbunden",
"wizardSkipForNow": "Vorerst überspringen",
"wizardDeviceNameLabel": "Gerätename",
"wizardDeviceNameHelp": "Dieser Name wird an andere MeshCore-Nutzer ausgesendet.",
"wizardConfigRegionLabel": "Konfigurationsregion",
"wizardConfigRegionHelp": "Verwendet die vollständige offizielle Liste der MeshCore-Voreinstellungen. Standard ist EU/UK (Narrow).",
"wizardPresetNote1": "Stelle sicher, dass die ausgewählte Voreinstellung zu deinen lokalen Funkvorschriften passt.",
"wizardPresetNote2": "Die Liste entspricht dem offiziellen Preset-Feed des MeshCore-Konfigurationstools.",
"wizardPresetNote3": "EU/UK (Narrow) bleibt standardmäßig für das Onboarding ausgewählt.",
"wizardSaving": "Wird gespeichert...",
"wizardSaveAndContinue": "Speichern und fortfahren",
"wizardEnterDeviceName": "Gib vor dem Fortfahren einen Gerätenamen ein.",
"wizardDeviceSetupSaved": "{deviceName} mit {presetName} gespeichert.",
"wizardNetworkTitle": "Kontakte, Räume und Repeater",
"wizardNetworkDescription": "Der Kontakte-Tab organisiert das Netzwerk, das du entdeckst, und die Routen, die im Laufe der Zeit gelernt werden.",
"wizardNetworkFeature1": "Prüfe Teammitglieder, Repeater, Räume, Kanäle und ausstehende Adverts in einer Liste.",
"wizardNetworkFeature2": "Nutze Smart Ping, Raum-Login, gelernte Pfade und Pfad-Reset-Tools, wenn die Verbindung unübersichtlich wird.",
"wizardNetworkFeature3": "Erstelle Kanäle und verwalte Netzwerkziele, ohne die App zu verlassen.",
"wizardMapOpsTitle": "Karte, Spuren und geteilte Geometrie",
"wizardMapOpsDescription": "Die Karte der App ist direkt mit Nachrichten, Tracking und SAR-Overlays verbunden, statt nur ein separater Viewer zu sein.",
"wizardMapOpsFeature1": "Verfolge deine eigene Position, Teamstandorte und Bewegungsspuren auf der Karte.",
"wizardMapOpsFeature2": "Öffne Zeichnungen aus Nachrichten, prüfe sie inline und entferne sie bei Bedarf von der Karte.",
"wizardMapOpsFeature3": "Nutze Repeater-Kartenansichten und geteilte Overlays, um die Netzabdeckung im Feld zu verstehen.",
"wizardToolsTitle": "Werkzeuge jenseits von Nachrichten",
"wizardToolsDescription": "Es gibt hier mehr als nur die vier Haupttabs. Die App enthält außerdem Konfiguration, Diagnose und optionale Sensor-Workflows.",
"wizardToolsFeature1": "Öffne die Gerätekonfiguration, um Funkparameter, Telemetrie, Sendeleistung und Begleitdetails zu ändern.",
"wizardToolsFeature2": "Aktiviere den Sensoren-Tab, wenn du überwachte Sensor-Dashboards und schnelle Aktualisierungen möchtest.",
"wizardToolsFeature3": "Nutze Paketprotokolle, Spektrumsuche und Entwicklerdiagnosen zur Fehlersuche im Mesh.",
"contactInSensors": "In Sensoren",
"contactAddToSensors": "Zu Sensoren hinzufügen",
"contactSetPath": "Pfad festlegen",
"contactAddedToSensors": "{contactName} zu Sensoren hinzugefügt",
"contactFailedToClearRoute": "Pfad konnte nicht gelöscht werden: {error}",
"contactRouteCleared": "Pfad gelöscht",
"contactRouteSet": "Pfad gesetzt: {route}",
"contactFailedToSetRoute": "Pfad konnte nicht gesetzt werden: {error}"
} }

View File

@@ -2251,5 +2251,57 @@
"rawDump": "Ακατέργαστα δεδομένα", "rawDump": "Ακατέργαστα δεδομένα",
"cannotRetryMissingRecipient": "Δεν είναι δυνατή η επανάληψη: λείπουν πληροφορίες παραλήπτη", "cannotRetryMissingRecipient": "Δεν είναι δυνατή η επανάληψη: λείπουν πληροφορίες παραλήπτη",
"voiceUnavailable": "Η φωνή δεν είναι διαθέσιμη αυτή τη στιγμή", "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.",
"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}"
} }

View File

@@ -2301,5 +2301,246 @@
}, },
"requestingVoice": "Requesting voice", "requestingVoice": "Requesting voice",
"@requestingVoice": { "@requestingVoice": {
},
"device": "device",
"@device": {
"description": "Generic device label"
},
"change": "Change",
"@change": {
"description": "Generic change action"
},
"wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.",
"@wizardOverviewDescription": {
"description": "Updated onboarding overview description"
},
"wizardOverviewFeature1": "Send direct, room, and channel messages from the main Messages tab.",
"@wizardOverviewFeature1": {
"description": "Updated onboarding overview feature 1"
},
"wizardOverviewFeature2": "Share SAR markers, map drawings, voice clips, and images over the mesh.",
"@wizardOverviewFeature2": {
"description": "Updated onboarding overview feature 2"
},
"wizardOverviewFeature3": "Connect over BLE or TCP, then manage the companion radio from inside the app.",
"@wizardOverviewFeature3": {
"description": "Updated onboarding overview feature 3"
},
"wizardMessagingTitle": "Messaging and Field Reports",
"@wizardMessagingTitle": {
"description": "Updated onboarding messaging page title"
},
"wizardMessagingDescription": "Messages are more than plain text here. The app already supports several operational payloads and transfer workflows.",
"@wizardMessagingDescription": {
"description": "Updated onboarding messaging page description"
},
"wizardMessagingFeature1": "Send direct messages, room posts, and channel traffic from one composer.",
"@wizardMessagingFeature1": {
"description": "Updated onboarding messaging feature 1"
},
"wizardMessagingFeature2": "Create SAR updates and reusable SAR templates for common field reports.",
"@wizardMessagingFeature2": {
"description": "Updated onboarding messaging feature 2"
},
"wizardMessagingFeature3": "Transfer voice sessions and images, with progress and airtime estimates in the UI.",
"@wizardMessagingFeature3": {
"description": "Updated onboarding messaging feature 3"
},
"wizardConnectDeviceTitle": "Connect device",
"@wizardConnectDeviceTitle": {
"description": "Onboarding connect device page title"
},
"wizardConnectDeviceDescription": "Connect your MeshCore radio, choose a name, and apply a radio preset before continuing.",
"@wizardConnectDeviceDescription": {
"description": "Onboarding connect device page description"
},
"wizardSetupBadge": "Setup",
"@wizardSetupBadge": {
"description": "Onboarding setup badge label"
},
"wizardOverviewBadge": "Overview",
"@wizardOverviewBadge": {
"description": "Onboarding overview badge label"
},
"wizardConnectedToDevice": "Connected to {deviceName}",
"@wizardConnectedToDevice": {
"description": "Onboarding connection status when connected",
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"wizardNoDeviceConnected": "No device connected yet",
"@wizardNoDeviceConnected": {
"description": "Onboarding connection status when disconnected"
},
"wizardSkipForNow": "Skip for now",
"@wizardSkipForNow": {
"description": "Onboarding skip current setup step action"
},
"wizardDeviceNameLabel": "Device name",
"@wizardDeviceNameLabel": {
"description": "Onboarding device name field label"
},
"wizardDeviceNameHelp": "This name is advertised to other MeshCore users.",
"@wizardDeviceNameHelp": {
"description": "Onboarding device name field helper text"
},
"wizardConfigRegionLabel": "Config region",
"@wizardConfigRegionLabel": {
"description": "Onboarding config region field label"
},
"wizardConfigRegionHelp": "Uses the full official MeshCore preset list. Default is EU/UK (Narrow).",
"@wizardConfigRegionHelp": {
"description": "Onboarding config region helper text"
},
"wizardPresetNote1": "Make sure the selected preset matches your local radio regulations.",
"@wizardPresetNote1": {
"description": "Onboarding preset note 1"
},
"wizardPresetNote2": "The list matches the official MeshCore config tool preset feed.",
"@wizardPresetNote2": {
"description": "Onboarding preset note 2"
},
"wizardPresetNote3": "EU/UK (Narrow) stays selected by default for onboarding.",
"@wizardPresetNote3": {
"description": "Onboarding preset note 3"
},
"wizardSaving": "Saving...",
"@wizardSaving": {
"description": "Onboarding saving button label"
},
"wizardSaveAndContinue": "Save and continue",
"@wizardSaveAndContinue": {
"description": "Onboarding save and continue button label"
},
"wizardEnterDeviceName": "Enter a device name before continuing.",
"@wizardEnterDeviceName": {
"description": "Onboarding validation when device name is missing"
},
"wizardDeviceSetupSaved": "Saved {deviceName} with {presetName}.",
"@wizardDeviceSetupSaved": {
"description": "Onboarding success message after saving device setup",
"placeholders": {
"deviceName": {
"type": "String"
},
"presetName": {
"type": "String"
}
}
},
"wizardNetworkTitle": "Contacts, Rooms, and Repeaters",
"@wizardNetworkTitle": {
"description": "Updated onboarding network page title"
},
"wizardNetworkDescription": "The Contacts tab organizes the network you discover and the routes you learn over time.",
"@wizardNetworkDescription": {
"description": "Updated onboarding network page description"
},
"wizardNetworkFeature1": "Review team members, repeaters, rooms, channels, and pending adverts in one list.",
"@wizardNetworkFeature1": {
"description": "Updated onboarding network feature 1"
},
"wizardNetworkFeature2": "Use smart ping, room login, learned paths, and route reset tools when connectivity gets messy.",
"@wizardNetworkFeature2": {
"description": "Updated onboarding network feature 2"
},
"wizardNetworkFeature3": "Create channels and manage network destinations without leaving the app.",
"@wizardNetworkFeature3": {
"description": "Updated onboarding network feature 3"
},
"wizardMapOpsTitle": "Map, Trails, and Shared Geometry",
"@wizardMapOpsTitle": {
"description": "Updated onboarding map page title"
},
"wizardMapOpsDescription": "The app map is tied directly into messaging, tracking, and SAR overlays instead of being a separate viewer.",
"@wizardMapOpsDescription": {
"description": "Updated onboarding map page description"
},
"wizardMapOpsFeature1": "Track your own position, teammate locations, and movement trails on the map.",
"@wizardMapOpsFeature1": {
"description": "Updated onboarding map feature 1"
},
"wizardMapOpsFeature2": "Open drawings from messages, preview them inline, and remove them from the map when needed.",
"@wizardMapOpsFeature2": {
"description": "Updated onboarding map feature 2"
},
"wizardMapOpsFeature3": "Use repeater map views and shared overlays to understand network reach in the field.",
"@wizardMapOpsFeature3": {
"description": "Updated onboarding map feature 3"
},
"wizardToolsTitle": "Tools Beyond Messaging",
"@wizardToolsTitle": {
"description": "Updated onboarding tools page title"
},
"wizardToolsDescription": "There is more here than the four main tabs. The app also includes configuration, diagnostics, and optional sensor workflows.",
"@wizardToolsDescription": {
"description": "Updated onboarding tools page description"
},
"wizardToolsFeature1": "Open device config to change radio settings, telemetry, TX power, and companion details.",
"@wizardToolsFeature1": {
"description": "Updated onboarding tools feature 1"
},
"wizardToolsFeature2": "Enable the Sensors tab when you want watched sensor dashboards and quick refresh actions.",
"@wizardToolsFeature2": {
"description": "Updated onboarding tools feature 2"
},
"wizardToolsFeature3": "Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.",
"@wizardToolsFeature3": {
"description": "Updated onboarding tools feature 3"
},
"contactInSensors": "In Sensors",
"@contactInSensors": {
"description": "Contact actions label when already in sensors"
},
"contactAddToSensors": "Add to Sensors",
"@contactAddToSensors": {
"description": "Contact actions label to add to sensors"
},
"contactSetPath": "Set path",
"@contactSetPath": {
"description": "Contact actions label to set route path"
},
"contactAddedToSensors": "{contactName} added to Sensors",
"@contactAddedToSensors": {
"description": "Snackbar after adding contact to sensors",
"placeholders": {
"contactName": {
"type": "String"
}
}
},
"contactFailedToClearRoute": "Failed to clear route: {error}",
"@contactFailedToClearRoute": {
"description": "Error when clearing a contact route fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"contactRouteCleared": "Route cleared",
"@contactRouteCleared": {
"description": "Snackbar after route is cleared"
},
"contactRouteSet": "Route set: {route}",
"@contactRouteSet": {
"description": "Snackbar after route is set",
"placeholders": {
"route": {
"type": "String"
}
}
},
"contactFailedToSetRoute": "Failed to set route: {error}",
"@contactFailedToSetRoute": {
"description": "Error when setting a contact route fails",
"placeholders": {
"error": {
"type": "String"
}
}
} }
} }

View File

@@ -1586,5 +1586,57 @@
"rawDump": "Volcado sin procesar", "rawDump": "Volcado sin procesar",
"cannotRetryMissingRecipient": "No se puede reintentar: falta información del destinatario", "cannotRetryMissingRecipient": "No se puede reintentar: falta información del destinatario",
"voiceUnavailable": "Voz no disponible en este momento", "voiceUnavailable": "Voz no disponible en este momento",
"requestingVoice": "Solicitando voz" "requestingVoice": "Solicitando voz",
"device": "dispositivo",
"change": "Cambiar",
"wizardOverviewDescription": "Esta aplicación reúne mensajería MeshCore, actualizaciones SAR de campo, mapas y herramientas del dispositivo en un solo lugar.",
"wizardOverviewFeature1": "Envía mensajes directos, publicaciones en salas y mensajes de canal desde la pestaña principal Mensajes.",
"wizardOverviewFeature2": "Comparte marcadores SAR, dibujos del mapa, clips de voz e imágenes a través de la malla.",
"wizardOverviewFeature3": "Conéctate por BLE o TCP y luego gestiona la radio complementaria desde la aplicación.",
"wizardMessagingTitle": "Mensajería e informes de campo",
"wizardMessagingDescription": "Aquí los mensajes son más que texto plano. La aplicación ya admite varias cargas operativas y flujos de transferencia.",
"wizardMessagingFeature1": "Envía mensajes directos, publicaciones en salas y tráfico de canal desde un solo editor.",
"wizardMessagingFeature2": "Crea actualizaciones SAR y plantillas SAR reutilizables para informes de campo habituales.",
"wizardMessagingFeature3": "Transfiere sesiones de voz e imágenes con progreso y estimaciones de tiempo de aire en la interfaz.",
"wizardConnectDeviceTitle": "Conectar dispositivo",
"wizardConnectDeviceDescription": "Conecta tu radio MeshCore, elige un nombre y aplica un ajuste predefinido antes de continuar.",
"wizardSetupBadge": "Configuración",
"wizardOverviewBadge": "Resumen",
"wizardConnectedToDevice": "Conectado a {deviceName}",
"wizardNoDeviceConnected": "Aún no hay ningún dispositivo conectado",
"wizardSkipForNow": "Omitir por ahora",
"wizardDeviceNameLabel": "Nombre del dispositivo",
"wizardDeviceNameHelp": "Este nombre se anuncia a otros usuarios de MeshCore.",
"wizardConfigRegionLabel": "Región de configuración",
"wizardConfigRegionHelp": "Usa la lista oficial completa de ajustes predefinidos de MeshCore. El valor predeterminado es EU/UK (Narrow).",
"wizardPresetNote1": "Asegúrate de que el ajuste seleccionado coincida con la normativa local de radio.",
"wizardPresetNote2": "La lista coincide con la fuente oficial de ajustes predefinidos de la herramienta de configuración de MeshCore.",
"wizardPresetNote3": "EU/UK (Narrow) sigue seleccionado por defecto durante la incorporación.",
"wizardSaving": "Guardando...",
"wizardSaveAndContinue": "Guardar y continuar",
"wizardEnterDeviceName": "Introduce un nombre de dispositivo antes de continuar.",
"wizardDeviceSetupSaved": "Se guardó {deviceName} con {presetName}.",
"wizardNetworkTitle": "Contactos, salas y repetidores",
"wizardNetworkDescription": "La pestaña Contactos organiza la red que descubres y las rutas que aprendes con el tiempo.",
"wizardNetworkFeature1": "Revisa miembros del equipo, repetidores, salas, canales y anuncios pendientes en una sola lista.",
"wizardNetworkFeature2": "Usa ping inteligente, acceso a salas, rutas aprendidas y herramientas de reinicio de ruta cuando la conectividad se complique.",
"wizardNetworkFeature3": "Crea canales y gestiona destinos de red sin salir de la aplicación.",
"wizardMapOpsTitle": "Mapa, rastros y geometría compartida",
"wizardMapOpsDescription": "El mapa de la aplicación está vinculado directamente a la mensajería, el seguimiento y las superposiciones SAR en lugar de ser un visor aparte.",
"wizardMapOpsFeature1": "Sigue tu propia posición, las ubicaciones de tus compañeros y los rastros de movimiento en el mapa.",
"wizardMapOpsFeature2": "Abre dibujos desde mensajes, prévisualos en línea y elimínalos del mapa cuando sea necesario.",
"wizardMapOpsFeature3": "Usa vistas de mapa de repetidores y superposiciones compartidas para comprender el alcance de la red sobre el terreno.",
"wizardToolsTitle": "Herramientas más allá de la mensajería",
"wizardToolsDescription": "Aquí hay más que las cuatro pestañas principales. La aplicación también incluye configuración, diagnóstico y flujos opcionales de sensores.",
"wizardToolsFeature1": "Abre la configuración del dispositivo para cambiar ajustes de radio, telemetría, potencia TX y detalles del equipo complementario.",
"wizardToolsFeature2": "Activa la pestaña Sensores cuando quieras paneles de sensores vigilados y acciones de actualización rápida.",
"wizardToolsFeature3": "Usa registros de paquetes, escaneo de espectro y diagnósticos de desarrollador al solucionar problemas de la malla.",
"contactInSensors": "En Sensores",
"contactAddToSensors": "Añadir a Sensores",
"contactSetPath": "Definir ruta",
"contactAddedToSensors": "{contactName} se añadió a Sensores",
"contactFailedToClearRoute": "No se pudo borrar la ruta: {error}",
"contactRouteCleared": "Ruta borrada",
"contactRouteSet": "Ruta establecida: {route}",
"contactFailedToSetRoute": "No se pudo establecer la ruta: {error}"
} }

View File

@@ -1589,5 +1589,57 @@
"rawDump": "Vidage brut", "rawDump": "Vidage brut",
"cannotRetryMissingRecipient": "Impossible de réessayer : informations du destinataire manquantes", "cannotRetryMissingRecipient": "Impossible de réessayer : informations du destinataire manquantes",
"voiceUnavailable": "Voix indisponible pour le moment", "voiceUnavailable": "Voix indisponible pour le moment",
"requestingVoice": "Demande de voix" "requestingVoice": "Demande de voix",
"device": "appareil",
"change": "Modifier",
"wizardOverviewDescription": "Cette application réunit la messagerie MeshCore, les mises à jour SAR sur le terrain, la cartographie et les outils de l'appareil au même endroit.",
"wizardOverviewFeature1": "Envoyez des messages directs, des publications de salon et des messages de canal depuis l'onglet principal Messages.",
"wizardOverviewFeature2": "Partagez des marqueurs SAR, des dessins de carte, des extraits vocaux et des images sur le réseau maillé.",
"wizardOverviewFeature3": "Connectez-vous en BLE ou en TCP, puis gérez la radio compagnon directement dans l'application.",
"wizardMessagingTitle": "Messagerie et rapports de terrain",
"wizardMessagingDescription": "Ici, les messages sont plus que du texte brut. L'application prend déjà en charge plusieurs charges opérationnelles et flux de transfert.",
"wizardMessagingFeature1": "Envoyez des messages directs, des publications de salon et du trafic de canal depuis un seul compositeur.",
"wizardMessagingFeature2": "Créez des mises à jour SAR et des modèles SAR réutilisables pour les rapports de terrain courants.",
"wizardMessagingFeature3": "Transférez des sessions vocales et des images avec la progression et les estimations de temps d'antenne dans l'interface.",
"wizardConnectDeviceTitle": "Connecter l'appareil",
"wizardConnectDeviceDescription": "Connectez votre radio MeshCore, choisissez un nom et appliquez un préréglage radio avant de continuer.",
"wizardSetupBadge": "Configuration",
"wizardOverviewBadge": "Aperçu",
"wizardConnectedToDevice": "Connecté à {deviceName}",
"wizardNoDeviceConnected": "Aucun appareil connecté pour le moment",
"wizardSkipForNow": "Ignorer pour l'instant",
"wizardDeviceNameLabel": "Nom de l'appareil",
"wizardDeviceNameHelp": "Ce nom est annoncé aux autres utilisateurs de MeshCore.",
"wizardConfigRegionLabel": "Région de configuration",
"wizardConfigRegionHelp": "Utilise la liste officielle complète des préréglages MeshCore. La valeur par défaut est EU/UK (Narrow).",
"wizardPresetNote1": "Assurez-vous que le préréglage sélectionné correspond à votre réglementation radio locale.",
"wizardPresetNote2": "La liste correspond au flux officiel des préréglages de l'outil de configuration MeshCore.",
"wizardPresetNote3": "EU/UK (Narrow) reste sélectionné par défaut pendant l'onboarding.",
"wizardSaving": "Enregistrement...",
"wizardSaveAndContinue": "Enregistrer et continuer",
"wizardEnterDeviceName": "Saisissez un nom d'appareil avant de continuer.",
"wizardDeviceSetupSaved": "{deviceName} enregistré avec {presetName}.",
"wizardNetworkTitle": "Contacts, salons et répéteurs",
"wizardNetworkDescription": "L'onglet Contacts organise le réseau que vous découvrez et les routes apprises au fil du temps.",
"wizardNetworkFeature1": "Consultez les membres de l'équipe, les répéteurs, les salons, les canaux et les annonces en attente dans une seule liste.",
"wizardNetworkFeature2": "Utilisez le smart ping, la connexion aux salons, les chemins appris et les outils de réinitialisation des routes lorsque la connectivité devient chaotique.",
"wizardNetworkFeature3": "Créez des canaux et gérez les destinations réseau sans quitter l'application.",
"wizardMapOpsTitle": "Carte, traces et géométrie partagée",
"wizardMapOpsDescription": "La carte de l'application est directement liée à la messagerie, au suivi et aux couches SAR au lieu d'être un simple visualiseur séparé.",
"wizardMapOpsFeature1": "Suivez votre position, celles de vos coéquipiers et les traces de déplacement sur la carte.",
"wizardMapOpsFeature2": "Ouvrez les dessins depuis les messages, prévisualisez-les en ligne et supprimez-les de la carte si nécessaire.",
"wizardMapOpsFeature3": "Utilisez les vues de carte des répéteurs et les couches partagées pour comprendre la portée du réseau sur le terrain.",
"wizardToolsTitle": "Outils au-delà de la messagerie",
"wizardToolsDescription": "Il y a plus ici que les quatre onglets principaux. L'application inclut aussi la configuration, le diagnostic et des workflows de capteurs optionnels.",
"wizardToolsFeature1": "Ouvrez la configuration de l'appareil pour modifier les réglages radio, la télémétrie, la puissance TX et les détails du compagnon.",
"wizardToolsFeature2": "Activez l'onglet Capteurs lorsque vous voulez des tableaux de bord surveillés et des actions d'actualisation rapide.",
"wizardToolsFeature3": "Utilisez les journaux de paquets, le balayage du spectre et les diagnostics développeur pour dépanner le réseau maillé.",
"contactInSensors": "Dans Capteurs",
"contactAddToSensors": "Ajouter aux capteurs",
"contactSetPath": "Définir le chemin",
"contactAddedToSensors": "{contactName} ajouté aux capteurs",
"contactFailedToClearRoute": "Échec de l'effacement de la route : {error}",
"contactRouteCleared": "Route effacée",
"contactRouteSet": "Route définie : {route}",
"contactFailedToSetRoute": "Échec du réglage de la route : {error}"
} }

View File

@@ -511,5 +511,57 @@
"rawDump": "Sirovi ispis", "rawDump": "Sirovi ispis",
"cannotRetryMissingRecipient": "Nije moguće ponoviti: nedostaju informacije o primatelju", "cannotRetryMissingRecipient": "Nije moguće ponoviti: nedostaju informacije o primatelju",
"voiceUnavailable": "Glas trenutno nije dostupan", "voiceUnavailable": "Glas trenutno nije dostupan",
"requestingVoice": "Zahtjev za glasom" "requestingVoice": "Zahtjev za glasom",
"device": "uređaj",
"change": "Promijeni",
"wizardOverviewDescription": "Ova aplikacija objedinjuje MeshCore poruke, SAR ažuriranja s terena, karte i alate za uređaj na jednom mjestu.",
"wizardOverviewFeature1": "Šaljite izravne poruke, objave u sobama i poruke kanala iz glavne kartice Poruke.",
"wizardOverviewFeature2": "Dijelite SAR oznake, crteže karte, glasovne isječke i slike preko mesh mreže.",
"wizardOverviewFeature3": "Povežite se putem BLE-a ili TCP-a, a zatim upravljajte pratećim radijem izravno iz aplikacije.",
"wizardMessagingTitle": "Poruke i terenska izvješća",
"wizardMessagingDescription": "Poruke ovdje nisu samo običan tekst. Aplikacija već podržava više operativnih sadržaja i tijekova prijenosa.",
"wizardMessagingFeature1": "Šaljite izravne poruke, objave u sobama i promet kanala iz jednog uređivača.",
"wizardMessagingFeature2": "Stvarajte SAR ažuriranja i višekratne SAR predloške za uobičajena terenska izvješća.",
"wizardMessagingFeature3": "Prenosite glasovne sesije i slike uz prikaz napretka i procjene vremena prijenosa u sučelju.",
"wizardConnectDeviceTitle": "Poveži uređaj",
"wizardConnectDeviceDescription": "Povežite svoj MeshCore radio, odaberite naziv i primijenite radijski predložak prije nastavka.",
"wizardSetupBadge": "Postavljanje",
"wizardOverviewBadge": "Pregled",
"wizardConnectedToDevice": "Povezano s uređajem {deviceName}",
"wizardNoDeviceConnected": "Još nema povezanog uređaja",
"wizardSkipForNow": "Preskoči za sada",
"wizardDeviceNameLabel": "Naziv uređaja",
"wizardDeviceNameHelp": "Taj se naziv oglašava drugim korisnicima MeshCorea.",
"wizardConfigRegionLabel": "Regija konfiguracije",
"wizardConfigRegionHelp": "Koristi puni službeni popis MeshCore predložaka. Zadana je opcija EU/UK (Narrow).",
"wizardPresetNote1": "Provjerite odgovara li odabrani predložak lokalnim radijskim propisima.",
"wizardPresetNote2": "Popis odgovara službenom izvoru predložaka alata MeshCore config.",
"wizardPresetNote3": "EU/UK (Narrow) ostaje zadano odabran tijekom uvodnog postavljanja.",
"wizardSaving": "Spremanje...",
"wizardSaveAndContinue": "Spremi i nastavi",
"wizardEnterDeviceName": "Unesite naziv uređaja prije nastavka.",
"wizardDeviceSetupSaved": "Spremljeno {deviceName} s postavkom {presetName}.",
"wizardNetworkTitle": "Kontakti, sobe i repetitori",
"wizardNetworkDescription": "Kartica Kontakti organizira mrežu koju otkrijete i rute koje s vremenom naučite.",
"wizardNetworkFeature1": "Pregledajte članove tima, repetitore, sobe, kanale i oglase na čekanju na jednom popisu.",
"wizardNetworkFeature2": "Koristite pametni ping, prijavu u sobe, naučene putanje i alate za resetiranje ruta kada povezanost postane neuredna.",
"wizardNetworkFeature3": "Stvarajte kanale i upravljajte mrežnim odredištima bez napuštanja aplikacije.",
"wizardMapOpsTitle": "Karta, tragovi i dijeljena geometrija",
"wizardMapOpsDescription": "Karta u aplikaciji izravno je povezana s porukama, praćenjem i SAR slojevima umjesto da bude zaseban preglednik.",
"wizardMapOpsFeature1": "Pratite vlastitu poziciju, lokacije suigrača i tragove kretanja na karti.",
"wizardMapOpsFeature2": "Otvorite crteže iz poruka, pregledajte ih unutar sučelja i po potrebi ih uklonite s karte.",
"wizardMapOpsFeature3": "Koristite karte repetitora i dijeljene slojeve kako biste razumjeli doseg mreže na terenu.",
"wizardToolsTitle": "Alati izvan poruka",
"wizardToolsDescription": "Ovdje postoji više od četiri glavne kartice. Aplikacija uključuje i konfiguraciju, dijagnostiku te opcionalne radne tokove senzora.",
"wizardToolsFeature1": "Otvorite konfiguraciju uređaja kako biste promijenili radijske postavke, telemetriju, TX snagu i detalje pratećeg uređaja.",
"wizardToolsFeature2": "Omogućite karticu Senzori kada želite nadzorne ploče praćenih senzora i brza osvježavanja.",
"wizardToolsFeature3": "Koristite zapisnike paketa, skeniranje spektra i razvojnu dijagnostiku pri rješavanju problema s mesh mrežom.",
"contactInSensors": "U Senzorima",
"contactAddToSensors": "Dodaj u Senzore",
"contactSetPath": "Postavi putanju",
"contactAddedToSensors": "{contactName} dodan u Senzore",
"contactFailedToClearRoute": "Brisanje rute nije uspjelo: {error}",
"contactRouteCleared": "Ruta obrisana",
"contactRouteSet": "Ruta postavljena: {route}",
"contactFailedToSetRoute": "Postavljanje rute nije uspjelo: {error}"
} }

View File

@@ -1589,5 +1589,57 @@
"rawDump": "Dump grezzo", "rawDump": "Dump grezzo",
"cannotRetryMissingRecipient": "Impossibile riprovare: informazioni destinatario mancanti", "cannotRetryMissingRecipient": "Impossibile riprovare: informazioni destinatario mancanti",
"voiceUnavailable": "Voce non disponibile al momento", "voiceUnavailable": "Voce non disponibile al momento",
"requestingVoice": "Richiesta voce" "requestingVoice": "Richiesta voce",
"device": "dispositivo",
"change": "Modifica",
"wizardOverviewDescription": "Questa app riunisce messaggistica MeshCore, aggiornamenti SAR sul campo, mappe e strumenti del dispositivo in un unico posto.",
"wizardOverviewFeature1": "Invia messaggi diretti, post nelle stanze e messaggi di canale dalla scheda principale Messaggi.",
"wizardOverviewFeature2": "Condividi marcatori SAR, disegni sulla mappa, clip vocali e immagini sulla mesh.",
"wizardOverviewFeature3": "Connettiti tramite BLE o TCP e poi gestisci la radio companion direttamente dall'app.",
"wizardMessagingTitle": "Messaggistica e rapporti sul campo",
"wizardMessagingDescription": "Qui i messaggi sono più del semplice testo. L'app supporta già diversi payload operativi e flussi di trasferimento.",
"wizardMessagingFeature1": "Invia messaggi diretti, post nelle stanze e traffico di canale da un unico compositore.",
"wizardMessagingFeature2": "Crea aggiornamenti SAR e modelli SAR riutilizzabili per i rapporti sul campo più comuni.",
"wizardMessagingFeature3": "Trasferisci sessioni vocali e immagini, con avanzamento e stime del tempo d'aria nell'interfaccia.",
"wizardConnectDeviceTitle": "Connetti dispositivo",
"wizardConnectDeviceDescription": "Connetti la tua radio MeshCore, scegli un nome e applica un preset radio prima di continuare.",
"wizardSetupBadge": "Configurazione",
"wizardOverviewBadge": "Panoramica",
"wizardConnectedToDevice": "Connesso a {deviceName}",
"wizardNoDeviceConnected": "Nessun dispositivo ancora connesso",
"wizardSkipForNow": "Salta per ora",
"wizardDeviceNameLabel": "Nome del dispositivo",
"wizardDeviceNameHelp": "Questo nome viene annunciato agli altri utenti MeshCore.",
"wizardConfigRegionLabel": "Regione di configurazione",
"wizardConfigRegionHelp": "Usa l'elenco ufficiale completo dei preset MeshCore. Il valore predefinito è EU/UK (Narrow).",
"wizardPresetNote1": "Assicurati che il preset selezionato corrisponda alle normative radio locali.",
"wizardPresetNote2": "L'elenco corrisponde al feed ufficiale dei preset dello strumento di configurazione MeshCore.",
"wizardPresetNote3": "EU/UK (Narrow) resta selezionato per impostazione predefinita durante l'onboarding.",
"wizardSaving": "Salvataggio...",
"wizardSaveAndContinue": "Salva e continua",
"wizardEnterDeviceName": "Inserisci un nome dispositivo prima di continuare.",
"wizardDeviceSetupSaved": "{deviceName} salvato con {presetName}.",
"wizardNetworkTitle": "Contatti, stanze e ripetitori",
"wizardNetworkDescription": "La scheda Contatti organizza la rete che scopri e i percorsi che apprendi nel tempo.",
"wizardNetworkFeature1": "Controlla membri del team, ripetitori, stanze, canali e annunci in sospeso in un unico elenco.",
"wizardNetworkFeature2": "Usa smart ping, accesso alle stanze, percorsi appresi e strumenti di reset delle route quando la connettività diventa complicata.",
"wizardNetworkFeature3": "Crea canali e gestisci le destinazioni di rete senza uscire dall'app.",
"wizardMapOpsTitle": "Mappa, tracce e geometria condivisa",
"wizardMapOpsDescription": "La mappa dell'app è collegata direttamente a messaggistica, tracciamento e overlay SAR invece di essere un visualizzatore separato.",
"wizardMapOpsFeature1": "Monitora la tua posizione, le posizioni dei compagni e le tracce di movimento sulla mappa.",
"wizardMapOpsFeature2": "Apri i disegni dai messaggi, visualizzali in anteprima in linea e rimuovili dalla mappa quando serve.",
"wizardMapOpsFeature3": "Usa le viste mappa dei ripetitori e gli overlay condivisi per capire la copertura della rete sul campo.",
"wizardToolsTitle": "Strumenti oltre la messaggistica",
"wizardToolsDescription": "Qui c'è più delle quattro schede principali. L'app include anche configurazione, diagnostica e flussi sensori opzionali.",
"wizardToolsFeature1": "Apri la configurazione del dispositivo per cambiare impostazioni radio, telemetria, potenza TX e dettagli del companion.",
"wizardToolsFeature2": "Abilita la scheda Sensori quando vuoi dashboard monitorate e azioni di aggiornamento rapido.",
"wizardToolsFeature3": "Usa log dei pacchetti, scansione dello spettro e diagnostica sviluppatore per risolvere problemi della mesh.",
"contactInSensors": "Nei Sensori",
"contactAddToSensors": "Aggiungi ai Sensori",
"contactSetPath": "Imposta percorso",
"contactAddedToSensors": "{contactName} aggiunto a Sensori",
"contactFailedToClearRoute": "Impossibile cancellare il percorso: {error}",
"contactRouteCleared": "Percorso cancellato",
"contactRouteSet": "Percorso impostato: {route}",
"contactFailedToSetRoute": "Impossibile impostare il percorso: {error}"
} }

View File

@@ -3203,6 +3203,318 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Requesting voice'** /// **'Requesting voice'**
String get requestingVoice; String get requestingVoice;
/// Generic device label
///
/// In en, this message translates to:
/// **'device'**
String get device;
/// Generic change action
///
/// In en, this message translates to:
/// **'Change'**
String get change;
/// Updated onboarding overview description
///
/// In en, this message translates to:
/// **'This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.'**
String get wizardOverviewDescription;
/// Updated onboarding overview feature 1
///
/// In en, this message translates to:
/// **'Send direct, room, and channel messages from the main Messages tab.'**
String get wizardOverviewFeature1;
/// Updated onboarding overview feature 2
///
/// In en, this message translates to:
/// **'Share SAR markers, map drawings, voice clips, and images over the mesh.'**
String get wizardOverviewFeature2;
/// Updated onboarding overview feature 3
///
/// In en, this message translates to:
/// **'Connect over BLE or TCP, then manage the companion radio from inside the app.'**
String get wizardOverviewFeature3;
/// Updated onboarding messaging page title
///
/// In en, this message translates to:
/// **'Messaging and Field Reports'**
String get wizardMessagingTitle;
/// Updated onboarding messaging page description
///
/// In en, this message translates to:
/// **'Messages are more than plain text here. The app already supports several operational payloads and transfer workflows.'**
String get wizardMessagingDescription;
/// Updated onboarding messaging feature 1
///
/// In en, this message translates to:
/// **'Send direct messages, room posts, and channel traffic from one composer.'**
String get wizardMessagingFeature1;
/// Updated onboarding messaging feature 2
///
/// In en, this message translates to:
/// **'Create SAR updates and reusable SAR templates for common field reports.'**
String get wizardMessagingFeature2;
/// Updated onboarding messaging feature 3
///
/// In en, this message translates to:
/// **'Transfer voice sessions and images, with progress and airtime estimates in the UI.'**
String get wizardMessagingFeature3;
/// Onboarding connect device page title
///
/// In en, this message translates to:
/// **'Connect device'**
String get wizardConnectDeviceTitle;
/// Onboarding connect device page description
///
/// In en, this message translates to:
/// **'Connect your MeshCore radio, choose a name, and apply a radio preset before continuing.'**
String get wizardConnectDeviceDescription;
/// Onboarding setup badge label
///
/// In en, this message translates to:
/// **'Setup'**
String get wizardSetupBadge;
/// Onboarding overview badge label
///
/// In en, this message translates to:
/// **'Overview'**
String get wizardOverviewBadge;
/// Onboarding connection status when connected
///
/// In en, this message translates to:
/// **'Connected to {deviceName}'**
String wizardConnectedToDevice(String deviceName);
/// Onboarding connection status when disconnected
///
/// In en, this message translates to:
/// **'No device connected yet'**
String get wizardNoDeviceConnected;
/// Onboarding skip current setup step action
///
/// In en, this message translates to:
/// **'Skip for now'**
String get wizardSkipForNow;
/// Onboarding device name field label
///
/// In en, this message translates to:
/// **'Device name'**
String get wizardDeviceNameLabel;
/// Onboarding device name field helper text
///
/// In en, this message translates to:
/// **'This name is advertised to other MeshCore users.'**
String get wizardDeviceNameHelp;
/// Onboarding config region field label
///
/// In en, this message translates to:
/// **'Config region'**
String get wizardConfigRegionLabel;
/// Onboarding config region helper text
///
/// In en, this message translates to:
/// **'Uses the full official MeshCore preset list. Default is EU/UK (Narrow).'**
String get wizardConfigRegionHelp;
/// Onboarding preset note 1
///
/// In en, this message translates to:
/// **'Make sure the selected preset matches your local radio regulations.'**
String get wizardPresetNote1;
/// Onboarding preset note 2
///
/// In en, this message translates to:
/// **'The list matches the official MeshCore config tool preset feed.'**
String get wizardPresetNote2;
/// Onboarding preset note 3
///
/// In en, this message translates to:
/// **'EU/UK (Narrow) stays selected by default for onboarding.'**
String get wizardPresetNote3;
/// Onboarding saving button label
///
/// In en, this message translates to:
/// **'Saving...'**
String get wizardSaving;
/// Onboarding save and continue button label
///
/// In en, this message translates to:
/// **'Save and continue'**
String get wizardSaveAndContinue;
/// Onboarding validation when device name is missing
///
/// In en, this message translates to:
/// **'Enter a device name before continuing.'**
String get wizardEnterDeviceName;
/// Onboarding success message after saving device setup
///
/// In en, this message translates to:
/// **'Saved {deviceName} with {presetName}.'**
String wizardDeviceSetupSaved(String deviceName, String presetName);
/// Updated onboarding network page title
///
/// In en, this message translates to:
/// **'Contacts, Rooms, and Repeaters'**
String get wizardNetworkTitle;
/// Updated onboarding network page description
///
/// In en, this message translates to:
/// **'The Contacts tab organizes the network you discover and the routes you learn over time.'**
String get wizardNetworkDescription;
/// Updated onboarding network feature 1
///
/// In en, this message translates to:
/// **'Review team members, repeaters, rooms, channels, and pending adverts in one list.'**
String get wizardNetworkFeature1;
/// Updated onboarding network feature 2
///
/// In en, this message translates to:
/// **'Use smart ping, room login, learned paths, and route reset tools when connectivity gets messy.'**
String get wizardNetworkFeature2;
/// Updated onboarding network feature 3
///
/// In en, this message translates to:
/// **'Create channels and manage network destinations without leaving the app.'**
String get wizardNetworkFeature3;
/// Updated onboarding map page title
///
/// In en, this message translates to:
/// **'Map, Trails, and Shared Geometry'**
String get wizardMapOpsTitle;
/// Updated onboarding map page description
///
/// In en, this message translates to:
/// **'The app map is tied directly into messaging, tracking, and SAR overlays instead of being a separate viewer.'**
String get wizardMapOpsDescription;
/// Updated onboarding map feature 1
///
/// In en, this message translates to:
/// **'Track your own position, teammate locations, and movement trails on the map.'**
String get wizardMapOpsFeature1;
/// Updated onboarding map feature 2
///
/// In en, this message translates to:
/// **'Open drawings from messages, preview them inline, and remove them from the map when needed.'**
String get wizardMapOpsFeature2;
/// Updated onboarding map feature 3
///
/// In en, this message translates to:
/// **'Use repeater map views and shared overlays to understand network reach in the field.'**
String get wizardMapOpsFeature3;
/// Updated onboarding tools page title
///
/// In en, this message translates to:
/// **'Tools Beyond Messaging'**
String get wizardToolsTitle;
/// Updated onboarding tools page description
///
/// In en, this message translates to:
/// **'There is more here than the four main tabs. The app also includes configuration, diagnostics, and optional sensor workflows.'**
String get wizardToolsDescription;
/// Updated onboarding tools feature 1
///
/// In en, this message translates to:
/// **'Open device config to change radio settings, telemetry, TX power, and companion details.'**
String get wizardToolsFeature1;
/// Updated onboarding tools feature 2
///
/// In en, this message translates to:
/// **'Enable the Sensors tab when you want watched sensor dashboards and quick refresh actions.'**
String get wizardToolsFeature2;
/// Updated onboarding tools feature 3
///
/// In en, this message translates to:
/// **'Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.'**
String get wizardToolsFeature3;
/// Contact actions label when already in sensors
///
/// In en, this message translates to:
/// **'In Sensors'**
String get contactInSensors;
/// Contact actions label to add to sensors
///
/// In en, this message translates to:
/// **'Add to Sensors'**
String get contactAddToSensors;
/// Contact actions label to set route path
///
/// In en, this message translates to:
/// **'Set path'**
String get contactSetPath;
/// Snackbar after adding contact to sensors
///
/// In en, this message translates to:
/// **'{contactName} added to Sensors'**
String contactAddedToSensors(String contactName);
/// Error when clearing a contact route fails
///
/// In en, this message translates to:
/// **'Failed to clear route: {error}'**
String contactFailedToClearRoute(String error);
/// Snackbar after route is cleared
///
/// In en, this message translates to:
/// **'Route cleared'**
String get contactRouteCleared;
/// Snackbar after route is set
///
/// In en, this message translates to:
/// **'Route set: {route}'**
String contactRouteSet(String route);
/// Error when setting a contact route fails
///
/// In en, this message translates to:
/// **'Failed to set route: {error}'**
String contactFailedToSetRoute(String error);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View File

@@ -1734,4 +1734,199 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get requestingVoice => 'Sprachnachricht wird angefordert'; String get requestingVoice => 'Sprachnachricht wird angefordert';
@override
String get device => 'Gerät';
@override
String get change => 'Ändern';
@override
String get wizardOverviewDescription =>
'Diese App vereint MeshCore-Nachrichten, SAR-Feldupdates, Karten und Gerätetools an einem Ort.';
@override
String get wizardOverviewFeature1 =>
'Sende Direkt-, Raum- und Kanalnachrichten aus dem Haupttab \"Nachrichten\".';
@override
String get wizardOverviewFeature2 =>
'Teile SAR-Markierungen, Kartenzeichnungen, Sprachclips und Bilder über das Mesh.';
@override
String get wizardOverviewFeature3 =>
'Verbinde dich per BLE oder TCP und verwalte dann das Begleitfunkgerät direkt in der App.';
@override
String get wizardMessagingTitle => 'Nachrichten und Feldberichte';
@override
String get wizardMessagingDescription =>
'Nachrichten sind hier mehr als nur Klartext. Die App unterstützt bereits mehrere operative Datentypen und Übertragungsabläufe.';
@override
String get wizardMessagingFeature1 =>
'Sende Direktnachrichten, Raumbeiträge und Kanalverkehr aus einem einzigen Editor.';
@override
String get wizardMessagingFeature2 =>
'Erstelle SAR-Updates und wiederverwendbare SAR-Vorlagen für häufige Feldberichte.';
@override
String get wizardMessagingFeature3 =>
'Übertrage Sprachsitzungen und Bilder mit Fortschritts- und Airtime-Anzeigen in der Oberfläche.';
@override
String get wizardConnectDeviceTitle => 'Gerät verbinden';
@override
String get wizardConnectDeviceDescription =>
'Verbinde dein MeshCore-Funkgerät, wähle einen Namen und wende ein Funkprofil an, bevor du fortfährst.';
@override
String get wizardSetupBadge => 'Einrichtung';
@override
String get wizardOverviewBadge => 'Überblick';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Verbunden mit $deviceName';
}
@override
String get wizardNoDeviceConnected => 'Noch kein Gerät verbunden';
@override
String get wizardSkipForNow => 'Vorerst überspringen';
@override
String get wizardDeviceNameLabel => 'Gerätename';
@override
String get wizardDeviceNameHelp =>
'Dieser Name wird an andere MeshCore-Nutzer ausgesendet.';
@override
String get wizardConfigRegionLabel => 'Konfigurationsregion';
@override
String get wizardConfigRegionHelp =>
'Verwendet die vollständige offizielle Liste der MeshCore-Voreinstellungen. Standard ist EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Stelle sicher, dass die ausgewählte Voreinstellung zu deinen lokalen Funkvorschriften passt.';
@override
String get wizardPresetNote2 =>
'Die Liste entspricht dem offiziellen Preset-Feed des MeshCore-Konfigurationstools.';
@override
String get wizardPresetNote3 =>
'EU/UK (Narrow) bleibt standardmäßig für das Onboarding ausgewählt.';
@override
String get wizardSaving => 'Wird gespeichert...';
@override
String get wizardSaveAndContinue => 'Speichern und fortfahren';
@override
String get wizardEnterDeviceName =>
'Gib vor dem Fortfahren einen Gerätenamen ein.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return '$deviceName mit $presetName gespeichert.';
}
@override
String get wizardNetworkTitle => 'Kontakte, Räume und Repeater';
@override
String get wizardNetworkDescription =>
'Der Kontakte-Tab organisiert das Netzwerk, das du entdeckst, und die Routen, die im Laufe der Zeit gelernt werden.';
@override
String get wizardNetworkFeature1 =>
'Prüfe Teammitglieder, Repeater, Räume, Kanäle und ausstehende Adverts in einer Liste.';
@override
String get wizardNetworkFeature2 =>
'Nutze Smart Ping, Raum-Login, gelernte Pfade und Pfad-Reset-Tools, wenn die Verbindung unübersichtlich wird.';
@override
String get wizardNetworkFeature3 =>
'Erstelle Kanäle und verwalte Netzwerkziele, ohne die App zu verlassen.';
@override
String get wizardMapOpsTitle => 'Karte, Spuren und geteilte Geometrie';
@override
String get wizardMapOpsDescription =>
'Die Karte der App ist direkt mit Nachrichten, Tracking und SAR-Overlays verbunden, statt nur ein separater Viewer zu sein.';
@override
String get wizardMapOpsFeature1 =>
'Verfolge deine eigene Position, Teamstandorte und Bewegungsspuren auf der Karte.';
@override
String get wizardMapOpsFeature2 =>
'Öffne Zeichnungen aus Nachrichten, prüfe sie inline und entferne sie bei Bedarf von der Karte.';
@override
String get wizardMapOpsFeature3 =>
'Nutze Repeater-Kartenansichten und geteilte Overlays, um die Netzabdeckung im Feld zu verstehen.';
@override
String get wizardToolsTitle => 'Werkzeuge jenseits von Nachrichten';
@override
String get wizardToolsDescription =>
'Es gibt hier mehr als nur die vier Haupttabs. Die App enthält außerdem Konfiguration, Diagnose und optionale Sensor-Workflows.';
@override
String get wizardToolsFeature1 =>
'Öffne die Gerätekonfiguration, um Funkparameter, Telemetrie, Sendeleistung und Begleitdetails zu ändern.';
@override
String get wizardToolsFeature2 =>
'Aktiviere den Sensoren-Tab, wenn du überwachte Sensor-Dashboards und schnelle Aktualisierungen möchtest.';
@override
String get wizardToolsFeature3 =>
'Nutze Paketprotokolle, Spektrumsuche und Entwicklerdiagnosen zur Fehlersuche im Mesh.';
@override
String get contactInSensors => 'In Sensoren';
@override
String get contactAddToSensors => 'Zu Sensoren hinzufügen';
@override
String get contactSetPath => 'Pfad festlegen';
@override
String contactAddedToSensors(String contactName) {
return '$contactName zu Sensoren hinzugefügt';
}
@override
String contactFailedToClearRoute(String error) {
return 'Pfad konnte nicht gelöscht werden: $error';
}
@override
String get contactRouteCleared => 'Pfad gelöscht';
@override
String contactRouteSet(String route) {
return 'Pfad gesetzt: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Pfad konnte nicht gesetzt werden: $error';
}
} }

View File

@@ -1741,4 +1741,199 @@ class AppLocalizationsEl extends AppLocalizations {
@override @override
String get requestingVoice => 'Αίτηση φωνής'; String get requestingVoice => 'Αίτηση φωνής';
@override
String get device => 'συσκευή';
@override
String get change => 'Αλλαγή';
@override
String get wizardOverviewDescription =>
'Αυτή η εφαρμογή συνδυάζει μηνύματα MeshCore, ενημερώσεις πεδίου SAR, χαρτογράφηση και εργαλεία συσκευής σε ένα σημείο.';
@override
String get wizardOverviewFeature1 =>
'Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και μηνύματα καναλιού από την κύρια καρτέλα Μηνύματα.';
@override
String get wizardOverviewFeature2 =>
'Μοιραστείτε δείκτες SAR, σχεδιάσεις χάρτη, ηχητικά αποσπάσματα και εικόνες μέσω του mesh.';
@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 =>
'Χρησιμοποιήστε smart 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';
}
} }

View File

@@ -1708,4 +1708,198 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get requestingVoice => 'Requesting voice'; String get requestingVoice => 'Requesting voice';
@override
String get device => 'device';
@override
String get change => 'Change';
@override
String get wizardOverviewDescription =>
'This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.';
@override
String get wizardOverviewFeature1 =>
'Send direct, room, and channel messages from the main Messages tab.';
@override
String get wizardOverviewFeature2 =>
'Share SAR markers, map drawings, voice clips, and images over the mesh.';
@override
String get wizardOverviewFeature3 =>
'Connect over BLE or TCP, then manage the companion radio from inside the app.';
@override
String get wizardMessagingTitle => 'Messaging and Field Reports';
@override
String get wizardMessagingDescription =>
'Messages are more than plain text here. The app already supports several operational payloads and transfer workflows.';
@override
String get wizardMessagingFeature1 =>
'Send direct messages, room posts, and channel traffic from one composer.';
@override
String get wizardMessagingFeature2 =>
'Create SAR updates and reusable SAR templates for common field reports.';
@override
String get wizardMessagingFeature3 =>
'Transfer voice sessions and images, with progress and airtime estimates in the UI.';
@override
String get wizardConnectDeviceTitle => 'Connect device';
@override
String get wizardConnectDeviceDescription =>
'Connect your MeshCore radio, choose a name, and apply a radio preset before continuing.';
@override
String get wizardSetupBadge => 'Setup';
@override
String get wizardOverviewBadge => 'Overview';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Connected to $deviceName';
}
@override
String get wizardNoDeviceConnected => 'No device connected yet';
@override
String get wizardSkipForNow => 'Skip for now';
@override
String get wizardDeviceNameLabel => 'Device name';
@override
String get wizardDeviceNameHelp =>
'This name is advertised to other MeshCore users.';
@override
String get wizardConfigRegionLabel => 'Config region';
@override
String get wizardConfigRegionHelp =>
'Uses the full official MeshCore preset list. Default is EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Make sure the selected preset matches your local radio regulations.';
@override
String get wizardPresetNote2 =>
'The list matches the official MeshCore config tool preset feed.';
@override
String get wizardPresetNote3 =>
'EU/UK (Narrow) stays selected by default for onboarding.';
@override
String get wizardSaving => 'Saving...';
@override
String get wizardSaveAndContinue => 'Save and continue';
@override
String get wizardEnterDeviceName => 'Enter a device name before continuing.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return 'Saved $deviceName with $presetName.';
}
@override
String get wizardNetworkTitle => 'Contacts, Rooms, and Repeaters';
@override
String get wizardNetworkDescription =>
'The Contacts tab organizes the network you discover and the routes you learn over time.';
@override
String get wizardNetworkFeature1 =>
'Review team members, repeaters, rooms, channels, and pending adverts in one list.';
@override
String get wizardNetworkFeature2 =>
'Use smart ping, room login, learned paths, and route reset tools when connectivity gets messy.';
@override
String get wizardNetworkFeature3 =>
'Create channels and manage network destinations without leaving the app.';
@override
String get wizardMapOpsTitle => 'Map, Trails, and Shared Geometry';
@override
String get wizardMapOpsDescription =>
'The app map is tied directly into messaging, tracking, and SAR overlays instead of being a separate viewer.';
@override
String get wizardMapOpsFeature1 =>
'Track your own position, teammate locations, and movement trails on the map.';
@override
String get wizardMapOpsFeature2 =>
'Open drawings from messages, preview them inline, and remove them from the map when needed.';
@override
String get wizardMapOpsFeature3 =>
'Use repeater map views and shared overlays to understand network reach in the field.';
@override
String get wizardToolsTitle => 'Tools Beyond Messaging';
@override
String get wizardToolsDescription =>
'There is more here than the four main tabs. The app also includes configuration, diagnostics, and optional sensor workflows.';
@override
String get wizardToolsFeature1 =>
'Open device config to change radio settings, telemetry, TX power, and companion details.';
@override
String get wizardToolsFeature2 =>
'Enable the Sensors tab when you want watched sensor dashboards and quick refresh actions.';
@override
String get wizardToolsFeature3 =>
'Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.';
@override
String get contactInSensors => 'In Sensors';
@override
String get contactAddToSensors => 'Add to Sensors';
@override
String get contactSetPath => 'Set path';
@override
String contactAddedToSensors(String contactName) {
return '$contactName added to Sensors';
}
@override
String contactFailedToClearRoute(String error) {
return 'Failed to clear route: $error';
}
@override
String get contactRouteCleared => 'Route cleared';
@override
String contactRouteSet(String route) {
return 'Route set: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Failed to set route: $error';
}
} }

View File

@@ -1736,4 +1736,200 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get requestingVoice => 'Solicitando voz'; String get requestingVoice => 'Solicitando voz';
@override
String get device => 'dispositivo';
@override
String get change => 'Cambiar';
@override
String get wizardOverviewDescription =>
'Esta aplicación reúne mensajería MeshCore, actualizaciones SAR de campo, mapas y herramientas del dispositivo en un solo lugar.';
@override
String get wizardOverviewFeature1 =>
'Envía mensajes directos, publicaciones en salas y mensajes de canal desde la pestaña principal Mensajes.';
@override
String get wizardOverviewFeature2 =>
'Comparte marcadores SAR, dibujos del mapa, clips de voz e imágenes a través de la malla.';
@override
String get wizardOverviewFeature3 =>
'Conéctate por BLE o TCP y luego gestiona la radio complementaria desde la aplicación.';
@override
String get wizardMessagingTitle => 'Mensajería e informes de campo';
@override
String get wizardMessagingDescription =>
'Aquí los mensajes son más que texto plano. La aplicación ya admite varias cargas operativas y flujos de transferencia.';
@override
String get wizardMessagingFeature1 =>
'Envía mensajes directos, publicaciones en salas y tráfico de canal desde un solo editor.';
@override
String get wizardMessagingFeature2 =>
'Crea actualizaciones SAR y plantillas SAR reutilizables para informes de campo habituales.';
@override
String get wizardMessagingFeature3 =>
'Transfiere sesiones de voz e imágenes con progreso y estimaciones de tiempo de aire en la interfaz.';
@override
String get wizardConnectDeviceTitle => 'Conectar dispositivo';
@override
String get wizardConnectDeviceDescription =>
'Conecta tu radio MeshCore, elige un nombre y aplica un ajuste predefinido antes de continuar.';
@override
String get wizardSetupBadge => 'Configuración';
@override
String get wizardOverviewBadge => 'Resumen';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Conectado a $deviceName';
}
@override
String get wizardNoDeviceConnected =>
'Aún no hay ningún dispositivo conectado';
@override
String get wizardSkipForNow => 'Omitir por ahora';
@override
String get wizardDeviceNameLabel => 'Nombre del dispositivo';
@override
String get wizardDeviceNameHelp =>
'Este nombre se anuncia a otros usuarios de MeshCore.';
@override
String get wizardConfigRegionLabel => 'Región de configuración';
@override
String get wizardConfigRegionHelp =>
'Usa la lista oficial completa de ajustes predefinidos de MeshCore. El valor predeterminado es EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Asegúrate de que el ajuste seleccionado coincida con la normativa local de radio.';
@override
String get wizardPresetNote2 =>
'La lista coincide con la fuente oficial de ajustes predefinidos de la herramienta de configuración de MeshCore.';
@override
String get wizardPresetNote3 =>
'EU/UK (Narrow) sigue seleccionado por defecto durante la incorporación.';
@override
String get wizardSaving => 'Guardando...';
@override
String get wizardSaveAndContinue => 'Guardar y continuar';
@override
String get wizardEnterDeviceName =>
'Introduce un nombre de dispositivo antes de continuar.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return 'Se guardó $deviceName con $presetName.';
}
@override
String get wizardNetworkTitle => 'Contactos, salas y repetidores';
@override
String get wizardNetworkDescription =>
'La pestaña Contactos organiza la red que descubres y las rutas que aprendes con el tiempo.';
@override
String get wizardNetworkFeature1 =>
'Revisa miembros del equipo, repetidores, salas, canales y anuncios pendientes en una sola lista.';
@override
String get wizardNetworkFeature2 =>
'Usa ping inteligente, acceso a salas, rutas aprendidas y herramientas de reinicio de ruta cuando la conectividad se complique.';
@override
String get wizardNetworkFeature3 =>
'Crea canales y gestiona destinos de red sin salir de la aplicación.';
@override
String get wizardMapOpsTitle => 'Mapa, rastros y geometría compartida';
@override
String get wizardMapOpsDescription =>
'El mapa de la aplicación está vinculado directamente a la mensajería, el seguimiento y las superposiciones SAR en lugar de ser un visor aparte.';
@override
String get wizardMapOpsFeature1 =>
'Sigue tu propia posición, las ubicaciones de tus compañeros y los rastros de movimiento en el mapa.';
@override
String get wizardMapOpsFeature2 =>
'Abre dibujos desde mensajes, prévisualos en línea y elimínalos del mapa cuando sea necesario.';
@override
String get wizardMapOpsFeature3 =>
'Usa vistas de mapa de repetidores y superposiciones compartidas para comprender el alcance de la red sobre el terreno.';
@override
String get wizardToolsTitle => 'Herramientas más allá de la mensajería';
@override
String get wizardToolsDescription =>
'Aquí hay más que las cuatro pestañas principales. La aplicación también incluye configuración, diagnóstico y flujos opcionales de sensores.';
@override
String get wizardToolsFeature1 =>
'Abre la configuración del dispositivo para cambiar ajustes de radio, telemetría, potencia TX y detalles del equipo complementario.';
@override
String get wizardToolsFeature2 =>
'Activa la pestaña Sensores cuando quieras paneles de sensores vigilados y acciones de actualización rápida.';
@override
String get wizardToolsFeature3 =>
'Usa registros de paquetes, escaneo de espectro y diagnósticos de desarrollador al solucionar problemas de la malla.';
@override
String get contactInSensors => 'En Sensores';
@override
String get contactAddToSensors => 'Añadir a Sensores';
@override
String get contactSetPath => 'Definir ruta';
@override
String contactAddedToSensors(String contactName) {
return '$contactName se añadió a Sensores';
}
@override
String contactFailedToClearRoute(String error) {
return 'No se pudo borrar la ruta: $error';
}
@override
String get contactRouteCleared => 'Ruta borrada';
@override
String contactRouteSet(String route) {
return 'Ruta establecida: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'No se pudo establecer la ruta: $error';
}
} }

View File

@@ -1740,4 +1740,200 @@ class AppLocalizationsFr extends AppLocalizations {
@override @override
String get requestingVoice => 'Demande de voix'; String get requestingVoice => 'Demande de voix';
@override
String get device => 'appareil';
@override
String get change => 'Modifier';
@override
String get wizardOverviewDescription =>
'Cette application réunit la messagerie MeshCore, les mises à jour SAR sur le terrain, la cartographie et les outils de l\'appareil au même endroit.';
@override
String get wizardOverviewFeature1 =>
'Envoyez des messages directs, des publications de salon et des messages de canal depuis l\'onglet principal Messages.';
@override
String get wizardOverviewFeature2 =>
'Partagez des marqueurs SAR, des dessins de carte, des extraits vocaux et des images sur le réseau maillé.';
@override
String get wizardOverviewFeature3 =>
'Connectez-vous en BLE ou en TCP, puis gérez la radio compagnon directement dans l\'application.';
@override
String get wizardMessagingTitle => 'Messagerie et rapports de terrain';
@override
String get wizardMessagingDescription =>
'Ici, les messages sont plus que du texte brut. L\'application prend déjà en charge plusieurs charges opérationnelles et flux de transfert.';
@override
String get wizardMessagingFeature1 =>
'Envoyez des messages directs, des publications de salon et du trafic de canal depuis un seul compositeur.';
@override
String get wizardMessagingFeature2 =>
'Créez des mises à jour SAR et des modèles SAR réutilisables pour les rapports de terrain courants.';
@override
String get wizardMessagingFeature3 =>
'Transférez des sessions vocales et des images avec la progression et les estimations de temps d\'antenne dans l\'interface.';
@override
String get wizardConnectDeviceTitle => 'Connecter l\'appareil';
@override
String get wizardConnectDeviceDescription =>
'Connectez votre radio MeshCore, choisissez un nom et appliquez un préréglage radio avant de continuer.';
@override
String get wizardSetupBadge => 'Configuration';
@override
String get wizardOverviewBadge => 'Aperçu';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Connecté à $deviceName';
}
@override
String get wizardNoDeviceConnected =>
'Aucun appareil connecté pour le moment';
@override
String get wizardSkipForNow => 'Ignorer pour l\'instant';
@override
String get wizardDeviceNameLabel => 'Nom de l\'appareil';
@override
String get wizardDeviceNameHelp =>
'Ce nom est annoncé aux autres utilisateurs de MeshCore.';
@override
String get wizardConfigRegionLabel => 'Région de configuration';
@override
String get wizardConfigRegionHelp =>
'Utilise la liste officielle complète des préréglages MeshCore. La valeur par défaut est EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Assurez-vous que le préréglage sélectionné correspond à votre réglementation radio locale.';
@override
String get wizardPresetNote2 =>
'La liste correspond au flux officiel des préréglages de l\'outil de configuration MeshCore.';
@override
String get wizardPresetNote3 =>
'EU/UK (Narrow) reste sélectionné par défaut pendant l\'onboarding.';
@override
String get wizardSaving => 'Enregistrement...';
@override
String get wizardSaveAndContinue => 'Enregistrer et continuer';
@override
String get wizardEnterDeviceName =>
'Saisissez un nom d\'appareil avant de continuer.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return '$deviceName enregistré avec $presetName.';
}
@override
String get wizardNetworkTitle => 'Contacts, salons et répéteurs';
@override
String get wizardNetworkDescription =>
'L\'onglet Contacts organise le réseau que vous découvrez et les routes apprises au fil du temps.';
@override
String get wizardNetworkFeature1 =>
'Consultez les membres de l\'équipe, les répéteurs, les salons, les canaux et les annonces en attente dans une seule liste.';
@override
String get wizardNetworkFeature2 =>
'Utilisez le smart ping, la connexion aux salons, les chemins appris et les outils de réinitialisation des routes lorsque la connectivité devient chaotique.';
@override
String get wizardNetworkFeature3 =>
'Créez des canaux et gérez les destinations réseau sans quitter l\'application.';
@override
String get wizardMapOpsTitle => 'Carte, traces et géométrie partagée';
@override
String get wizardMapOpsDescription =>
'La carte de l\'application est directement liée à la messagerie, au suivi et aux couches SAR au lieu d\'être un simple visualiseur séparé.';
@override
String get wizardMapOpsFeature1 =>
'Suivez votre position, celles de vos coéquipiers et les traces de déplacement sur la carte.';
@override
String get wizardMapOpsFeature2 =>
'Ouvrez les dessins depuis les messages, prévisualisez-les en ligne et supprimez-les de la carte si nécessaire.';
@override
String get wizardMapOpsFeature3 =>
'Utilisez les vues de carte des répéteurs et les couches partagées pour comprendre la portée du réseau sur le terrain.';
@override
String get wizardToolsTitle => 'Outils au-delà de la messagerie';
@override
String get wizardToolsDescription =>
'Il y a plus ici que les quatre onglets principaux. L\'application inclut aussi la configuration, le diagnostic et des workflows de capteurs optionnels.';
@override
String get wizardToolsFeature1 =>
'Ouvrez la configuration de l\'appareil pour modifier les réglages radio, la télémétrie, la puissance TX et les détails du compagnon.';
@override
String get wizardToolsFeature2 =>
'Activez l\'onglet Capteurs lorsque vous voulez des tableaux de bord surveillés et des actions d\'actualisation rapide.';
@override
String get wizardToolsFeature3 =>
'Utilisez les journaux de paquets, le balayage du spectre et les diagnostics développeur pour dépanner le réseau maillé.';
@override
String get contactInSensors => 'Dans Capteurs';
@override
String get contactAddToSensors => 'Ajouter aux capteurs';
@override
String get contactSetPath => 'Définir le chemin';
@override
String contactAddedToSensors(String contactName) {
return '$contactName ajouté aux capteurs';
}
@override
String contactFailedToClearRoute(String error) {
return 'Échec de l\'effacement de la route : $error';
}
@override
String get contactRouteCleared => 'Route effacée';
@override
String contactRouteSet(String route) {
return 'Route définie : $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Échec du réglage de la route : $error';
}
} }

View File

@@ -1718,4 +1718,198 @@ class AppLocalizationsHr extends AppLocalizations {
@override @override
String get requestingVoice => 'Zahtjev za glasom'; String get requestingVoice => 'Zahtjev za glasom';
@override
String get device => 'uređaj';
@override
String get change => 'Promijeni';
@override
String get wizardOverviewDescription =>
'Ova aplikacija objedinjuje MeshCore poruke, SAR ažuriranja s terena, karte i alate za uređaj na jednom mjestu.';
@override
String get wizardOverviewFeature1 =>
'Šaljite izravne poruke, objave u sobama i poruke kanala iz glavne kartice Poruke.';
@override
String get wizardOverviewFeature2 =>
'Dijelite SAR oznake, crteže karte, glasovne isječke i slike preko mesh mreže.';
@override
String get wizardOverviewFeature3 =>
'Povežite se putem BLE-a ili TCP-a, a zatim upravljajte pratećim radijem izravno iz aplikacije.';
@override
String get wizardMessagingTitle => 'Poruke i terenska izvješća';
@override
String get wizardMessagingDescription =>
'Poruke ovdje nisu samo običan tekst. Aplikacija već podržava više operativnih sadržaja i tijekova prijenosa.';
@override
String get wizardMessagingFeature1 =>
'Šaljite izravne poruke, objave u sobama i promet kanala iz jednog uređivača.';
@override
String get wizardMessagingFeature2 =>
'Stvarajte SAR ažuriranja i višekratne SAR predloške za uobičajena terenska izvješća.';
@override
String get wizardMessagingFeature3 =>
'Prenosite glasovne sesije i slike uz prikaz napretka i procjene vremena prijenosa u sučelju.';
@override
String get wizardConnectDeviceTitle => 'Poveži uređaj';
@override
String get wizardConnectDeviceDescription =>
'Povežite svoj MeshCore radio, odaberite naziv i primijenite radijski predložak prije nastavka.';
@override
String get wizardSetupBadge => 'Postavljanje';
@override
String get wizardOverviewBadge => 'Pregled';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Povezano s uređajem $deviceName';
}
@override
String get wizardNoDeviceConnected => 'Još nema povezanog uređaja';
@override
String get wizardSkipForNow => 'Preskoči za sada';
@override
String get wizardDeviceNameLabel => 'Naziv uređaja';
@override
String get wizardDeviceNameHelp =>
'Taj se naziv oglašava drugim korisnicima MeshCorea.';
@override
String get wizardConfigRegionLabel => 'Regija konfiguracije';
@override
String get wizardConfigRegionHelp =>
'Koristi puni službeni popis MeshCore predložaka. Zadana je opcija EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Provjerite odgovara li odabrani predložak lokalnim radijskim propisima.';
@override
String get wizardPresetNote2 =>
'Popis odgovara službenom izvoru predložaka alata MeshCore config.';
@override
String get wizardPresetNote3 =>
'EU/UK (Narrow) ostaje zadano odabran tijekom uvodnog postavljanja.';
@override
String get wizardSaving => 'Spremanje...';
@override
String get wizardSaveAndContinue => 'Spremi i nastavi';
@override
String get wizardEnterDeviceName => 'Unesite naziv uređaja prije nastavka.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return 'Spremljeno $deviceName s postavkom $presetName.';
}
@override
String get wizardNetworkTitle => 'Kontakti, sobe i repetitori';
@override
String get wizardNetworkDescription =>
'Kartica Kontakti organizira mrežu koju otkrijete i rute koje s vremenom naučite.';
@override
String get wizardNetworkFeature1 =>
'Pregledajte članove tima, repetitore, sobe, kanale i oglase na čekanju na jednom popisu.';
@override
String get wizardNetworkFeature2 =>
'Koristite pametni ping, prijavu u sobe, naučene putanje i alate za resetiranje ruta kada povezanost postane neuredna.';
@override
String get wizardNetworkFeature3 =>
'Stvarajte kanale i upravljajte mrežnim odredištima bez napuštanja aplikacije.';
@override
String get wizardMapOpsTitle => 'Karta, tragovi i dijeljena geometrija';
@override
String get wizardMapOpsDescription =>
'Karta u aplikaciji izravno je povezana s porukama, praćenjem i SAR slojevima umjesto da bude zaseban preglednik.';
@override
String get wizardMapOpsFeature1 =>
'Pratite vlastitu poziciju, lokacije suigrača i tragove kretanja na karti.';
@override
String get wizardMapOpsFeature2 =>
'Otvorite crteže iz poruka, pregledajte ih unutar sučelja i po potrebi ih uklonite s karte.';
@override
String get wizardMapOpsFeature3 =>
'Koristite karte repetitora i dijeljene slojeve kako biste razumjeli doseg mreže na terenu.';
@override
String get wizardToolsTitle => 'Alati izvan poruka';
@override
String get wizardToolsDescription =>
'Ovdje postoji više od četiri glavne kartice. Aplikacija uključuje i konfiguraciju, dijagnostiku te opcionalne radne tokove senzora.';
@override
String get wizardToolsFeature1 =>
'Otvorite konfiguraciju uređaja kako biste promijenili radijske postavke, telemetriju, TX snagu i detalje pratećeg uređaja.';
@override
String get wizardToolsFeature2 =>
'Omogućite karticu Senzori kada želite nadzorne ploče praćenih senzora i brza osvježavanja.';
@override
String get wizardToolsFeature3 =>
'Koristite zapisnike paketa, skeniranje spektra i razvojnu dijagnostiku pri rješavanju problema s mesh mrežom.';
@override
String get contactInSensors => 'U Senzorima';
@override
String get contactAddToSensors => 'Dodaj u Senzore';
@override
String get contactSetPath => 'Postavi putanju';
@override
String contactAddedToSensors(String contactName) {
return '$contactName dodan u Senzore';
}
@override
String contactFailedToClearRoute(String error) {
return 'Brisanje rute nije uspjelo: $error';
}
@override
String get contactRouteCleared => 'Ruta obrisana';
@override
String contactRouteSet(String route) {
return 'Ruta postavljena: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Postavljanje rute nije uspjelo: $error';
}
} }

View File

@@ -1732,4 +1732,199 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get requestingVoice => 'Richiesta voce'; String get requestingVoice => 'Richiesta voce';
@override
String get device => 'dispositivo';
@override
String get change => 'Modifica';
@override
String get wizardOverviewDescription =>
'Questa app riunisce messaggistica MeshCore, aggiornamenti SAR sul campo, mappe e strumenti del dispositivo in un unico posto.';
@override
String get wizardOverviewFeature1 =>
'Invia messaggi diretti, post nelle stanze e messaggi di canale dalla scheda principale Messaggi.';
@override
String get wizardOverviewFeature2 =>
'Condividi marcatori SAR, disegni sulla mappa, clip vocali e immagini sulla mesh.';
@override
String get wizardOverviewFeature3 =>
'Connettiti tramite BLE o TCP e poi gestisci la radio companion direttamente dall\'app.';
@override
String get wizardMessagingTitle => 'Messaggistica e rapporti sul campo';
@override
String get wizardMessagingDescription =>
'Qui i messaggi sono più del semplice testo. L\'app supporta già diversi payload operativi e flussi di trasferimento.';
@override
String get wizardMessagingFeature1 =>
'Invia messaggi diretti, post nelle stanze e traffico di canale da un unico compositore.';
@override
String get wizardMessagingFeature2 =>
'Crea aggiornamenti SAR e modelli SAR riutilizzabili per i rapporti sul campo più comuni.';
@override
String get wizardMessagingFeature3 =>
'Trasferisci sessioni vocali e immagini, con avanzamento e stime del tempo d\'aria nell\'interfaccia.';
@override
String get wizardConnectDeviceTitle => 'Connetti dispositivo';
@override
String get wizardConnectDeviceDescription =>
'Connetti la tua radio MeshCore, scegli un nome e applica un preset radio prima di continuare.';
@override
String get wizardSetupBadge => 'Configurazione';
@override
String get wizardOverviewBadge => 'Panoramica';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Connesso a $deviceName';
}
@override
String get wizardNoDeviceConnected => 'Nessun dispositivo ancora connesso';
@override
String get wizardSkipForNow => 'Salta per ora';
@override
String get wizardDeviceNameLabel => 'Nome del dispositivo';
@override
String get wizardDeviceNameHelp =>
'Questo nome viene annunciato agli altri utenti MeshCore.';
@override
String get wizardConfigRegionLabel => 'Regione di configurazione';
@override
String get wizardConfigRegionHelp =>
'Usa l\'elenco ufficiale completo dei preset MeshCore. Il valore predefinito è EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Assicurati che il preset selezionato corrisponda alle normative radio locali.';
@override
String get wizardPresetNote2 =>
'L\'elenco corrisponde al feed ufficiale dei preset dello strumento di configurazione MeshCore.';
@override
String get wizardPresetNote3 =>
'EU/UK (Narrow) resta selezionato per impostazione predefinita durante l\'onboarding.';
@override
String get wizardSaving => 'Salvataggio...';
@override
String get wizardSaveAndContinue => 'Salva e continua';
@override
String get wizardEnterDeviceName =>
'Inserisci un nome dispositivo prima di continuare.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return '$deviceName salvato con $presetName.';
}
@override
String get wizardNetworkTitle => 'Contatti, stanze e ripetitori';
@override
String get wizardNetworkDescription =>
'La scheda Contatti organizza la rete che scopri e i percorsi che apprendi nel tempo.';
@override
String get wizardNetworkFeature1 =>
'Controlla membri del team, ripetitori, stanze, canali e annunci in sospeso in un unico elenco.';
@override
String get wizardNetworkFeature2 =>
'Usa smart ping, accesso alle stanze, percorsi appresi e strumenti di reset delle route quando la connettività diventa complicata.';
@override
String get wizardNetworkFeature3 =>
'Crea canali e gestisci le destinazioni di rete senza uscire dall\'app.';
@override
String get wizardMapOpsTitle => 'Mappa, tracce e geometria condivisa';
@override
String get wizardMapOpsDescription =>
'La mappa dell\'app è collegata direttamente a messaggistica, tracciamento e overlay SAR invece di essere un visualizzatore separato.';
@override
String get wizardMapOpsFeature1 =>
'Monitora la tua posizione, le posizioni dei compagni e le tracce di movimento sulla mappa.';
@override
String get wizardMapOpsFeature2 =>
'Apri i disegni dai messaggi, visualizzali in anteprima in linea e rimuovili dalla mappa quando serve.';
@override
String get wizardMapOpsFeature3 =>
'Usa le viste mappa dei ripetitori e gli overlay condivisi per capire la copertura della rete sul campo.';
@override
String get wizardToolsTitle => 'Strumenti oltre la messaggistica';
@override
String get wizardToolsDescription =>
'Qui c\'è più delle quattro schede principali. L\'app include anche configurazione, diagnostica e flussi sensori opzionali.';
@override
String get wizardToolsFeature1 =>
'Apri la configurazione del dispositivo per cambiare impostazioni radio, telemetria, potenza TX e dettagli del companion.';
@override
String get wizardToolsFeature2 =>
'Abilita la scheda Sensori quando vuoi dashboard monitorate e azioni di aggiornamento rapido.';
@override
String get wizardToolsFeature3 =>
'Usa log dei pacchetti, scansione dello spettro e diagnostica sviluppatore per risolvere problemi della mesh.';
@override
String get contactInSensors => 'Nei Sensori';
@override
String get contactAddToSensors => 'Aggiungi ai Sensori';
@override
String get contactSetPath => 'Imposta percorso';
@override
String contactAddedToSensors(String contactName) {
return '$contactName aggiunto a Sensori';
}
@override
String contactFailedToClearRoute(String error) {
return 'Impossibile cancellare il percorso: $error';
}
@override
String get contactRouteCleared => 'Percorso cancellato';
@override
String contactRouteSet(String route) {
return 'Percorso impostato: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Impossibile impostare il percorso: $error';
}
} }

View File

@@ -1729,4 +1729,199 @@ class AppLocalizationsPl extends AppLocalizations {
@override @override
String get requestingVoice => 'Pobieranie głosu'; String get requestingVoice => 'Pobieranie głosu';
@override
String get device => 'urządzenie';
@override
String get change => 'Zmień';
@override
String get wizardOverviewDescription =>
'Ta aplikacja łączy wiadomości MeshCore, terenowe aktualizacje SAR, mapy i narzędzia urządzenia w jednym miejscu.';
@override
String get wizardOverviewFeature1 =>
'Wysyłaj wiadomości bezpośrednie, wpisy pokojowe i wiadomości kanałowe z głównej karty Wiadomości.';
@override
String get wizardOverviewFeature2 =>
'Udostępniaj znaczniki SAR, rysunki mapy, klipy głosowe i obrazy przez sieć mesh.';
@override
String get wizardOverviewFeature3 =>
'Połącz się przez BLE lub TCP, a następnie zarządzaj radiem towarzyszącym bezpośrednio z aplikacji.';
@override
String get wizardMessagingTitle => 'Wiadomości i raporty terenowe';
@override
String get wizardMessagingDescription =>
'Wiadomości to tutaj coś więcej niż zwykły tekst. Aplikacja obsługuje już kilka typów danych operacyjnych i przepływów transferu.';
@override
String get wizardMessagingFeature1 =>
'Wysyłaj wiadomości bezpośrednie, wpisy pokojowe i ruch kanałowy z jednego edytora.';
@override
String get wizardMessagingFeature2 =>
'Twórz aktualizacje SAR i wielokrotnego użytku szablony SAR do typowych raportów terenowych.';
@override
String get wizardMessagingFeature3 =>
'Przesyłaj sesje głosowe i obrazy z postępem i szacowanym czasem transmisji w interfejsie.';
@override
String get wizardConnectDeviceTitle => 'Połącz urządzenie';
@override
String get wizardConnectDeviceDescription =>
'Podłącz radio MeshCore, wybierz nazwę i zastosuj profil radiowy przed kontynuacją.';
@override
String get wizardSetupBadge => 'Konfiguracja';
@override
String get wizardOverviewBadge => 'Przegląd';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Połączono z $deviceName';
}
@override
String get wizardNoDeviceConnected => 'Brak podłączonego urządzenia';
@override
String get wizardSkipForNow => 'Pomiń na razie';
@override
String get wizardDeviceNameLabel => 'Nazwa urządzenia';
@override
String get wizardDeviceNameHelp =>
'Ta nazwa jest rozgłaszana innym użytkownikom MeshCore.';
@override
String get wizardConfigRegionLabel => 'Region konfiguracji';
@override
String get wizardConfigRegionHelp =>
'Używa pełnej oficjalnej listy presetów MeshCore. Domyślnie wybrane jest EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Upewnij się, że wybrany preset jest zgodny z lokalnymi przepisami radiowymi.';
@override
String get wizardPresetNote2 =>
'Lista odpowiada oficjalnemu źródłu presetów narzędzia konfiguracji MeshCore.';
@override
String get wizardPresetNote3 =>
'EU/UK (Narrow) pozostaje domyślnie wybrane podczas wdrożenia.';
@override
String get wizardSaving => 'Zapisywanie...';
@override
String get wizardSaveAndContinue => 'Zapisz i kontynuuj';
@override
String get wizardEnterDeviceName =>
'Wprowadź nazwę urządzenia przed kontynuacją.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return 'Zapisano $deviceName z ustawieniem $presetName.';
}
@override
String get wizardNetworkTitle => 'Kontakty, pokoje i repeatery';
@override
String get wizardNetworkDescription =>
'Karta Kontakty porządkuje sieć, którą odkrywasz, oraz trasy, których uczysz się z czasem.';
@override
String get wizardNetworkFeature1 =>
'Przeglądaj członków zespołu, repeatery, pokoje, kanały i oczekujące ogłoszenia na jednej liście.';
@override
String get wizardNetworkFeature2 =>
'Używaj smart ping, logowania do pokoi, poznanych ścieżek i narzędzi resetowania tras, gdy łączność staje się chaotyczna.';
@override
String get wizardNetworkFeature3 =>
'Twórz kanały i zarządzaj celami sieciowymi bez opuszczania aplikacji.';
@override
String get wizardMapOpsTitle => 'Mapa, ślady i współdzielona geometria';
@override
String get wizardMapOpsDescription =>
'Mapa aplikacji jest bezpośrednio powiązana z wiadomościami, śledzeniem i nakładkami SAR zamiast być osobnym podglądem.';
@override
String get wizardMapOpsFeature1 =>
'Śledź własną pozycję, lokalizacje członków zespołu i ślady ruchu na mapie.';
@override
String get wizardMapOpsFeature2 =>
'Otwieraj rysunki z wiadomości, podglądaj je w miejscu i usuwaj z mapy w razie potrzeby.';
@override
String get wizardMapOpsFeature3 =>
'Korzystaj z widoków map repeaterów i współdzielonych nakładek, aby zrozumieć zasięg sieci w terenie.';
@override
String get wizardToolsTitle => 'Narzędzia poza wiadomościami';
@override
String get wizardToolsDescription =>
'Jest tu więcej niż cztery główne karty. Aplikacja obejmuje także konfigurację, diagnostykę i opcjonalne przepływy czujników.';
@override
String get wizardToolsFeature1 =>
'Otwórz konfigurację urządzenia, aby zmienić ustawienia radia, telemetrię, moc TX i szczegóły urządzenia towarzyszącego.';
@override
String get wizardToolsFeature2 =>
'Włącz kartę Czujniki, gdy chcesz mieć pulpity monitorowanych czujników i szybkie odświeżanie.';
@override
String get wizardToolsFeature3 =>
'Używaj logów pakietów, skanowania widma i diagnostyki deweloperskiej podczas rozwiązywania problemów z mesh.';
@override
String get contactInSensors => 'W Czujnikach';
@override
String get contactAddToSensors => 'Dodaj do Czujników';
@override
String get contactSetPath => 'Ustaw ścieżkę';
@override
String contactAddedToSensors(String contactName) {
return '$contactName dodano do Czujników';
}
@override
String contactFailedToClearRoute(String error) {
return 'Nie udało się wyczyścić trasy: $error';
}
@override
String get contactRouteCleared => 'Trasa wyczyszczona';
@override
String contactRouteSet(String route) {
return 'Ustawiono trasę: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Nie udało się ustawić trasy: $error';
}
} }

View File

@@ -1736,4 +1736,199 @@ class AppLocalizationsPt extends AppLocalizations {
@override @override
String get requestingVoice => 'Solicitando voz'; String get requestingVoice => 'Solicitando voz';
@override
String get device => 'dispositivo';
@override
String get change => 'Alterar';
@override
String get wizardOverviewDescription =>
'Este aplicativo reúne mensagens MeshCore, atualizações SAR em campo, mapas e ferramentas do dispositivo em um só lugar.';
@override
String get wizardOverviewFeature1 =>
'Envie mensagens diretas, publicações em salas e mensagens de canal pela guia principal Mensagens.';
@override
String get wizardOverviewFeature2 =>
'Compartilhe marcadores SAR, desenhos de mapa, clipes de voz e imagens pela malha.';
@override
String get wizardOverviewFeature3 =>
'Conecte-se via BLE ou TCP e depois gerencie o rádio complementar dentro do aplicativo.';
@override
String get wizardMessagingTitle => 'Mensagens e relatórios de campo';
@override
String get wizardMessagingDescription =>
'Aqui, as mensagens são mais do que texto simples. O aplicativo já oferece suporte a várias cargas operacionais e fluxos de transferência.';
@override
String get wizardMessagingFeature1 =>
'Envie mensagens diretas, publicações em salas e tráfego de canal a partir de um único compositor.';
@override
String get wizardMessagingFeature2 =>
'Crie atualizações SAR e modelos SAR reutilizáveis para relatórios de campo comuns.';
@override
String get wizardMessagingFeature3 =>
'Transfira sessões de voz e imagens com progresso e estimativas de tempo de transmissão na interface.';
@override
String get wizardConnectDeviceTitle => 'Conectar dispositivo';
@override
String get wizardConnectDeviceDescription =>
'Conecte seu rádio MeshCore, escolha um nome e aplique uma predefinição de rádio antes de continuar.';
@override
String get wizardSetupBadge => 'Configuração';
@override
String get wizardOverviewBadge => 'Visão geral';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Conectado a $deviceName';
}
@override
String get wizardNoDeviceConnected => 'Nenhum dispositivo conectado ainda';
@override
String get wizardSkipForNow => 'Ignorar por enquanto';
@override
String get wizardDeviceNameLabel => 'Nome do dispositivo';
@override
String get wizardDeviceNameHelp =>
'Esse nome é anunciado para outros usuários do MeshCore.';
@override
String get wizardConfigRegionLabel => 'Região de configuração';
@override
String get wizardConfigRegionHelp =>
'Usa a lista oficial completa de predefinições do MeshCore. O padrão é EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Certifique-se de que a predefinição selecionada corresponda às regulamentações locais de rádio.';
@override
String get wizardPresetNote2 =>
'A lista corresponde ao feed oficial de predefinições da ferramenta de configuração do MeshCore.';
@override
String get wizardPresetNote3 =>
'EU/UK (Narrow) permanece selecionado por padrão durante a configuração inicial.';
@override
String get wizardSaving => 'Salvando...';
@override
String get wizardSaveAndContinue => 'Salvar e continuar';
@override
String get wizardEnterDeviceName =>
'Digite um nome para o dispositivo antes de continuar.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return '$deviceName salvo com $presetName.';
}
@override
String get wizardNetworkTitle => 'Contatos, salas e repetidores';
@override
String get wizardNetworkDescription =>
'A guia Contatos organiza a rede que você descobre e as rotas aprendidas ao longo do tempo.';
@override
String get wizardNetworkFeature1 =>
'Revise membros da equipe, repetidores, salas, canais e anúncios pendentes em uma única lista.';
@override
String get wizardNetworkFeature2 =>
'Use ping inteligente, login em salas, caminhos aprendidos e ferramentas de redefinição de rota quando a conectividade ficar confusa.';
@override
String get wizardNetworkFeature3 =>
'Crie canais e gerencie destinos de rede sem sair do aplicativo.';
@override
String get wizardMapOpsTitle => 'Mapa, trilhas e geometria compartilhada';
@override
String get wizardMapOpsDescription =>
'O mapa do aplicativo está ligado diretamente às mensagens, ao rastreamento e às sobreposições SAR, em vez de ser um visualizador separado.';
@override
String get wizardMapOpsFeature1 =>
'Acompanhe sua própria posição, as localizações da equipe e as trilhas de movimento no mapa.';
@override
String get wizardMapOpsFeature2 =>
'Abra desenhos de mensagens, visualize-os em linha e remova-os do mapa quando necessário.';
@override
String get wizardMapOpsFeature3 =>
'Use vistas de mapa de repetidores e sobreposições compartilhadas para entender o alcance da rede em campo.';
@override
String get wizardToolsTitle => 'Ferramentas além das mensagens';
@override
String get wizardToolsDescription =>
'Há mais aqui do que as quatro guias principais. O aplicativo também inclui configuração, diagnóstico e fluxos opcionais de sensores.';
@override
String get wizardToolsFeature1 =>
'Abra a configuração do dispositivo para alterar ajustes de rádio, telemetria, potência TX e detalhes do equipamento complementar.';
@override
String get wizardToolsFeature2 =>
'Ative a guia Sensores quando quiser painéis monitorados e ações de atualização rápida.';
@override
String get wizardToolsFeature3 =>
'Use logs de pacotes, varredura de espectro e diagnóstico de desenvolvedor ao solucionar problemas da malha.';
@override
String get contactInSensors => 'Nos Sensores';
@override
String get contactAddToSensors => 'Adicionar aos Sensores';
@override
String get contactSetPath => 'Definir rota';
@override
String contactAddedToSensors(String contactName) {
return '$contactName adicionado a Sensores';
}
@override
String contactFailedToClearRoute(String error) {
return 'Falha ao limpar a rota: $error';
}
@override
String get contactRouteCleared => 'Rota limpa';
@override
String contactRouteSet(String route) {
return 'Rota definida: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Falha ao definir a rota: $error';
}
} }

View File

@@ -1726,4 +1726,199 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get requestingVoice => 'Запрос голоса'; String get requestingVoice => 'Запрос голоса';
@override
String get device => 'устройство';
@override
String get change => 'Изменить';
@override
String get wizardOverviewDescription =>
'Это приложение объединяет сообщения MeshCore, полевые обновления SAR, карты и инструменты устройства в одном месте.';
@override
String get wizardOverviewFeature1 =>
'Отправляйте личные сообщения, сообщения комнат и сообщения каналов из основной вкладки «Сообщения».';
@override
String get wizardOverviewFeature2 =>
'Делитесь SAR-маркерами, рисунками на карте, голосовыми клипами и изображениями через mesh-сеть.';
@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 config.';
@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 =>
'Используйте smart 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';
}
} }

View File

@@ -1718,4 +1718,198 @@ class AppLocalizationsSl extends AppLocalizations {
@override @override
String get requestingVoice => 'Zahteva za glasom'; String get requestingVoice => 'Zahteva za glasom';
@override
String get device => 'naprava';
@override
String get change => 'Spremeni';
@override
String get wizardOverviewDescription =>
'Ta aplikacija združuje sporočanje MeshCore, terenske SAR posodobitve, zemljevide in orodja za napravo na enem mestu.';
@override
String get wizardOverviewFeature1 =>
'Pošiljajte neposredna sporočila, objave v sobah in sporočila na kanalih iz glavnega zavihka Sporočila.';
@override
String get wizardOverviewFeature2 =>
'Delite SAR označevalce, risbe zemljevida, glasovne posnetke in slike prek mesh omrežja.';
@override
String get wizardOverviewFeature3 =>
'Povežite se prek BLE ali TCP in nato upravljajte spremljevalni radio kar iz aplikacije.';
@override
String get wizardMessagingTitle => 'Sporočanje in terenska poročila';
@override
String get wizardMessagingDescription =>
'Sporočila tukaj niso le navadno besedilo. Aplikacija že podpira več operativnih vrst vsebin in prenosnih tokov.';
@override
String get wizardMessagingFeature1 =>
'Pošiljajte neposredna sporočila, objave v sobah in promet na kanalih iz enega urejevalnika.';
@override
String get wizardMessagingFeature2 =>
'Ustvarjajte SAR posodobitve in večkrat uporabne SAR predloge za pogosta terenska poročila.';
@override
String get wizardMessagingFeature3 =>
'Prenašajte glasovne seje in slike, vmesnik pa prikazuje napredek in oceno časa prenosa.';
@override
String get wizardConnectDeviceTitle => 'Poveži napravo';
@override
String get wizardConnectDeviceDescription =>
'Povežite svoj MeshCore radio, izberite ime in uporabite radijski prednastavljeni profil, preden nadaljujete.';
@override
String get wizardSetupBadge => 'Nastavitev';
@override
String get wizardOverviewBadge => 'Pregled';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Povezano z: $deviceName';
}
@override
String get wizardNoDeviceConnected => 'Nobena naprava še ni povezana';
@override
String get wizardSkipForNow => 'Za zdaj preskoči';
@override
String get wizardDeviceNameLabel => 'Ime naprave';
@override
String get wizardDeviceNameHelp =>
'To ime se oglašuje drugim uporabnikom MeshCore.';
@override
String get wizardConfigRegionLabel => 'Konfiguracijska regija';
@override
String get wizardConfigRegionHelp =>
'Uporabi celoten uradni seznam prednastavitev MeshCore. Privzeto je izbran EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Prepričajte se, da izbrana prednastavitev ustreza lokalnim radijskim predpisom.';
@override
String get wizardPresetNote2 =>
'Seznam ustreza uradnemu viru prednastavitev orodja MeshCore config.';
@override
String get wizardPresetNote3 =>
'Za uvajanje ostane privzeto izbran EU/UK (Narrow).';
@override
String get wizardSaving => 'Shranjujem...';
@override
String get wizardSaveAndContinue => 'Shrani in nadaljuj';
@override
String get wizardEnterDeviceName => 'Pred nadaljevanjem vnesite ime naprave.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return 'Shranjeno $deviceName z nastavitvijo $presetName.';
}
@override
String get wizardNetworkTitle => 'Stiki, sobe in repetitorji';
@override
String get wizardNetworkDescription =>
'Zavihek Stiki organizira omrežje, ki ga odkrijete, in poti, ki se jih aplikacija nauči skozi čas.';
@override
String get wizardNetworkFeature1 =>
'Na enem seznamu preglejte člane ekipe, repetitorje, sobe, kanale in čakajoče oglase.';
@override
String get wizardNetworkFeature2 =>
'Uporabite smart ping, prijavo v sobe, naučene poti in ponastavitev poti, ko povezljivost postane neurejena.';
@override
String get wizardNetworkFeature3 =>
'Ustvarjajte kanale in upravljajte omrežne cilje, ne da bi zapustili aplikacijo.';
@override
String get wizardMapOpsTitle => 'Zemljevid, sledi in deljena geometrija';
@override
String get wizardMapOpsDescription =>
'Zemljevid v aplikaciji je neposredno povezan s sporočanjem, sledenjem in SAR prekrivanji, namesto da bi bil le ločen pregledovalnik.';
@override
String get wizardMapOpsFeature1 =>
'Spremljajte svoj položaj, lokacije sotekmovalcev in gibanje sledi na zemljevidu.';
@override
String get wizardMapOpsFeature2 =>
'Odprite risbe iz sporočil, jih predoglejte v vrstici in jih po potrebi odstranite z zemljevida.';
@override
String get wizardMapOpsFeature3 =>
'Uporabite poglede repetitorjev in deljena prekrivanja za razumevanje dosega omrežja na terenu.';
@override
String get wizardToolsTitle => 'Orodja onkraj sporočanja';
@override
String get wizardToolsDescription =>
'Tu je več kot le štirje glavni zavihki. Aplikacija vključuje tudi konfiguracijo, diagnostiko in neobvezne senzorske poteke.';
@override
String get wizardToolsFeature1 =>
'Odprite nastavitve naprave za spremembo radijskih nastavitev, telemetrije, oddajne moči in podatkov spremljevalne naprave.';
@override
String get wizardToolsFeature2 =>
'Omogočite zavihek Senzorji, kadar želite nadzorne plošče in hitra osveževanja spremljanih senzorjev.';
@override
String get wizardToolsFeature3 =>
'Uporabite dnevnik paketov, pregled spektra in razvojno diagnostiko pri odpravljanju težav v mesh omrežju.';
@override
String get contactInSensors => 'V senzorjih';
@override
String get contactAddToSensors => 'Dodaj v senzorje';
@override
String get contactSetPath => 'Nastavi pot';
@override
String contactAddedToSensors(String contactName) {
return '$contactName dodan v senzorje';
}
@override
String contactFailedToClearRoute(String error) {
return 'Brisanje poti ni uspelo: $error';
}
@override
String get contactRouteCleared => 'Pot izbrisana';
@override
String contactRouteSet(String route) {
return 'Pot nastavljena: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Nastavitev poti ni uspela: $error';
}
} }

View File

@@ -1720,4 +1720,198 @@ class AppLocalizationsTr extends AppLocalizations {
@override @override
String get requestingVoice => 'Ses isteniyor'; String get requestingVoice => 'Ses isteniyor';
@override
String get device => 'cihaz';
@override
String get change => 'Değiştir';
@override
String get wizardOverviewDescription =>
'Bu uygulama MeshCore mesajlaşmasını, SAR saha güncellemelerini, haritalamayı ve cihaz araçlarını tek bir yerde birleştirir.';
@override
String get wizardOverviewFeature1 =>
'Ana Mesajlar sekmesinden doğrudan mesajlar, oda gönderileri ve kanal mesajları gönderin.';
@override
String get wizardOverviewFeature2 =>
'Mesh üzerinden SAR işaretleri, harita çizimleri, ses klipleri ve görseller paylaşın.';
@override
String get wizardOverviewFeature3 =>
'BLE veya TCP ile bağlanın, ardından yardımcı radyoyu uygulamanın içinden yönetin.';
@override
String get wizardMessagingTitle => 'Mesajlaşma ve saha raporları';
@override
String get wizardMessagingDescription =>
'Buradaki mesajlar düz metinden fazlasıdır. Uygulama zaten çeşitli operasyonel yükleri ve aktarım akışlarını destekliyor.';
@override
String get wizardMessagingFeature1 =>
'Doğrudan mesajları, oda gönderilerini ve kanal trafiğini tek bir düzenleyiciden gönderin.';
@override
String get wizardMessagingFeature2 =>
'Yaygın saha raporları için SAR güncellemeleri ve yeniden kullanılabilir SAR şablonları oluşturun.';
@override
String get wizardMessagingFeature3 =>
'Arayüzde ilerleme ve yayın süresi tahminleriyle ses oturumları ve görseller aktarın.';
@override
String get wizardConnectDeviceTitle => 'Cihazı bağla';
@override
String get wizardConnectDeviceDescription =>
'MeshCore radyonuzu bağlayın, bir ad seçin ve devam etmeden önce bir radyo ön ayarı uygulayın.';
@override
String get wizardSetupBadge => 'Kurulum';
@override
String get wizardOverviewBadge => 'Genel bakış';
@override
String wizardConnectedToDevice(String deviceName) {
return '$deviceName cihazına bağlandı';
}
@override
String get wizardNoDeviceConnected => 'Henüz bağlı cihaz yok';
@override
String get wizardSkipForNow => 'Şimdilik atla';
@override
String get wizardDeviceNameLabel => 'Cihaz adı';
@override
String get wizardDeviceNameHelp =>
'Bu ad diğer MeshCore kullanıcılarına duyurulur.';
@override
String get wizardConfigRegionLabel => 'Yapılandırma bölgesi';
@override
String get wizardConfigRegionHelp =>
'MeshCore\'un resmi ön ayar listesinin tamamını kullanır. Varsayılan EU/UK (Narrow) olur.';
@override
String get wizardPresetNote1 =>
'Seçilen ön ayarın yerel radyo düzenlemelerinize uygun olduğundan emin olun.';
@override
String get wizardPresetNote2 =>
'Liste, MeshCore yapılandırma aracının resmi ön ayar akışıyla eşleşir.';
@override
String get wizardPresetNote3 =>
'Onboarding sırasında varsayılan olarak EU/UK (Narrow) seçili kalır.';
@override
String get wizardSaving => 'Kaydediliyor...';
@override
String get wizardSaveAndContinue => 'Kaydet ve devam et';
@override
String get wizardEnterDeviceName => 'Devam etmeden önce bir cihaz adı girin.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return '$deviceName, $presetName ile kaydedildi.';
}
@override
String get wizardNetworkTitle => 'Kişiler, odalar ve tekrarlayıcılar';
@override
String get wizardNetworkDescription =>
'Kişiler sekmesi keşfettiğiniz ağı ve zamanla öğrendiğiniz rotaları düzenler.';
@override
String get wizardNetworkFeature1 =>
'Ekip üyelerini, tekrarlayıcıları, odaları, kanalları ve bekleyen duyuruları tek listede inceleyin.';
@override
String get wizardNetworkFeature2 =>
'Bağlantı karmaşıklaştığında smart ping, oda girişi, öğrenilen yollar ve rota sıfırlama araçlarını kullanın.';
@override
String get wizardNetworkFeature3 =>
'Uygulamadan çıkmadan kanallar oluşturun ve ağ hedeflerini yönetin.';
@override
String get wizardMapOpsTitle => 'Harita, izler ve paylaşılan geometri';
@override
String get wizardMapOpsDescription =>
'Uygulama haritası ayrı bir görüntüleyici olmak yerine mesajlaşma, takip ve SAR katmanlarıyla doğrudan bağlantılıdır.';
@override
String get wizardMapOpsFeature1 =>
'Kendi konumunuzu, ekip arkadaşlarının konumlarını ve hareket izlerini harita üzerinde takip edin.';
@override
String get wizardMapOpsFeature2 =>
'Mesajlardaki çizimleri açın, satır içinde önizleyin ve gerektiğinde haritadan kaldırın.';
@override
String get wizardMapOpsFeature3 =>
'Sahadaki ağ kapsamasını anlamak için tekrarlayıcı harita görünümleri ve paylaşılan katmanları kullanın.';
@override
String get wizardToolsTitle => 'Mesajlaşma dışındaki araçlar';
@override
String get wizardToolsDescription =>
'Burada dört ana sekmeden fazlası var. Uygulama ayrıca yapılandırma, tanılama ve isteğe bağlı sensör akışları içerir.';
@override
String get wizardToolsFeature1 =>
'Radyo ayarlarını, telemetriyi, TX gücünü ve yardımcı cihaz ayrıntılarını değiştirmek için cihaz yapılandırmasınıın.';
@override
String get wizardToolsFeature2 =>
'İzlenen sensör panoları ve hızlı yenileme eylemleri istediğinizde Sensörler sekmesini etkinleştirin.';
@override
String get wizardToolsFeature3 =>
'Mesh sorunlarını giderirken paket günlükleri, spektrum taraması ve geliştirici tanılamalarını kullanın.';
@override
String get contactInSensors => 'Sensörlerde';
@override
String get contactAddToSensors => 'Sensörlere ekle';
@override
String get contactSetPath => 'Yol ayarla';
@override
String contactAddedToSensors(String contactName) {
return '$contactName Sensörlere eklendi';
}
@override
String contactFailedToClearRoute(String error) {
return 'Rota temizlenemedi: $error';
}
@override
String get contactRouteCleared => 'Rota temizlendi';
@override
String contactRouteSet(String route) {
return 'Rota ayarlandı: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Rota ayarlanamadı: $error';
}
} }

View File

@@ -1732,4 +1732,199 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get requestingVoice => 'Запит голосу'; String get requestingVoice => 'Запит голосу';
@override
String get device => 'пристрій';
@override
String get change => 'Змінити';
@override
String get wizardOverviewDescription =>
'Ця програма поєднує повідомлення MeshCore, польові оновлення SAR, карти та інструменти пристрою в одному місці.';
@override
String get wizardOverviewFeature1 =>
'Надсилайте особисті повідомлення, повідомлення кімнат і повідомлення каналів з основної вкладки «Повідомлення».';
@override
String get wizardOverviewFeature2 =>
'Діліться SAR-маркерами, малюнками на карті, голосовими кліпами та зображеннями через mesh-мережу.';
@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 config.';
@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 =>
'Використовуйте smart 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';
}
} }

View File

@@ -1656,4 +1656,175 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get requestingVoice => '正在请求语音'; String get requestingVoice => '正在请求语音';
@override
String get device => '设备';
@override
String get change => '更改';
@override
String get wizardOverviewDescription =>
'此应用将 MeshCore 消息、SAR 现场更新、地图功能和设备工具整合到同一处。';
@override
String get wizardOverviewFeature1 => '在主消息标签页中发送私聊消息、房间消息和频道消息。';
@override
String get wizardOverviewFeature2 => '通过 mesh 网络共享 SAR 标记、地图绘图、语音片段和图像。';
@override
String get wizardOverviewFeature3 => '通过 BLE 或 TCP 连接,然后直接在应用中管理配套电台。';
@override
String get wizardMessagingTitle => '消息与现场报告';
@override
String get wizardMessagingDescription => '这里的消息不只是纯文本。应用已经支持多种操作载荷和传输流程。';
@override
String get wizardMessagingFeature1 => '通过一个编辑器发送私聊消息、房间消息和频道流量。';
@override
String get wizardMessagingFeature2 => '为常见现场报告创建 SAR 更新和可复用的 SAR 模板。';
@override
String get wizardMessagingFeature3 => '传输语音会话和图像,并在界面中显示进度和空口时间估算。';
@override
String get wizardConnectDeviceTitle => '连接设备';
@override
String get wizardConnectDeviceDescription =>
'连接你的 MeshCore 电台,选择名称,并在继续前应用无线电预设。';
@override
String get wizardSetupBadge => '设置';
@override
String get wizardOverviewBadge => '概览';
@override
String wizardConnectedToDevice(String deviceName) {
return '已连接到 $deviceName';
}
@override
String get wizardNoDeviceConnected => '尚未连接设备';
@override
String get wizardSkipForNow => '暂时跳过';
@override
String get wizardDeviceNameLabel => '设备名称';
@override
String get wizardDeviceNameHelp => '此名称会广播给其他 MeshCore 用户。';
@override
String get wizardConfigRegionLabel => '配置区域';
@override
String get wizardConfigRegionHelp =>
'使用完整的官方 MeshCore 预设列表。默认值为 EU/UK (Narrow)。';
@override
String get wizardPresetNote1 => '请确认所选预设符合你所在地区的无线电规定。';
@override
String get wizardPresetNote2 => '该列表与 MeshCore 配置工具的官方预设源保持一致。';
@override
String get wizardPresetNote3 => '在引导设置中默认保持选中 EU/UK (Narrow)。';
@override
String get wizardSaving => '正在保存...';
@override
String get wizardSaveAndContinue => '保存并继续';
@override
String get wizardEnterDeviceName => '继续前请输入设备名称。';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return '已保存 $deviceName,配置为 $presetName';
}
@override
String get wizardNetworkTitle => '联系人、房间和中继器';
@override
String get wizardNetworkDescription => '联系人标签页会整理你发现的网络以及随着时间学习到的路由。';
@override
String get wizardNetworkFeature1 => '在一个列表中查看团队成员、中继器、房间、频道和待处理广播。';
@override
String get wizardNetworkFeature2 => '当连接变得混乱时,使用智能 ping、房间登录、已学习路径和路由重置工具。';
@override
String get wizardNetworkFeature3 => '无需离开应用即可创建频道并管理网络目的地。';
@override
String get wizardMapOpsTitle => '地图、轨迹与共享几何';
@override
String get wizardMapOpsDescription => '应用地图直接与消息、跟踪和 SAR 叠加层关联,而不是单独的查看器。';
@override
String get wizardMapOpsFeature1 => '在地图上跟踪你自己的位置、队友位置和移动轨迹。';
@override
String get wizardMapOpsFeature2 => '打开消息中的绘图,内联预览,并在需要时将其从地图上移除。';
@override
String get wizardMapOpsFeature3 => '使用中继器地图视图和共享叠加层来了解现场网络覆盖范围。';
@override
String get wizardToolsTitle => '消息之外的工具';
@override
String get wizardToolsDescription => '这里不只有四个主标签页。应用还包含配置、诊断和可选的传感器工作流。';
@override
String get wizardToolsFeature1 => '打开设备配置以更改无线电设置、遥测、TX 功率和配套设备详情。';
@override
String get wizardToolsFeature2 => '当你需要受监控的传感器面板和快速刷新操作时,启用传感器标签页。';
@override
String get wizardToolsFeature3 => '在排查 mesh 网络问题时使用数据包日志、频谱扫描和开发者诊断。';
@override
String get contactInSensors => '在传感器中';
@override
String get contactAddToSensors => '添加到传感器';
@override
String get contactSetPath => '设置路径';
@override
String contactAddedToSensors(String contactName) {
return '已将 $contactName 添加到传感器';
}
@override
String contactFailedToClearRoute(String error) {
return '清除路由失败:$error';
}
@override
String get contactRouteCleared => '路由已清除';
@override
String contactRouteSet(String route) {
return '路由已设置:$route';
}
@override
String contactFailedToSetRoute(String error) {
return '设置路由失败:$error';
}
} }

View File

@@ -2276,5 +2276,57 @@
"voiceUnavailable": "Głos jest obecnie niedostępny", "voiceUnavailable": "Głos jest obecnie niedostępny",
"@voiceUnavailable": {}, "@voiceUnavailable": {},
"requestingVoice": "Pobieranie głosu", "requestingVoice": "Pobieranie głosu",
"@requestingVoice": {} "@requestingVoice": {},
"device": "urządzenie",
"change": "Zmień",
"wizardOverviewDescription": "Ta aplikacja łączy wiadomości MeshCore, terenowe aktualizacje SAR, mapy i narzędzia urządzenia w jednym miejscu.",
"wizardOverviewFeature1": "Wysyłaj wiadomości bezpośrednie, wpisy pokojowe i wiadomości kanałowe z głównej karty Wiadomości.",
"wizardOverviewFeature2": "Udostępniaj znaczniki SAR, rysunki mapy, klipy głosowe i obrazy przez sieć mesh.",
"wizardOverviewFeature3": "Połącz się przez BLE lub TCP, a następnie zarządzaj radiem towarzyszącym bezpośrednio z aplikacji.",
"wizardMessagingTitle": "Wiadomości i raporty terenowe",
"wizardMessagingDescription": "Wiadomości to tutaj coś więcej niż zwykły tekst. Aplikacja obsługuje już kilka typów danych operacyjnych i przepływów transferu.",
"wizardMessagingFeature1": "Wysyłaj wiadomości bezpośrednie, wpisy pokojowe i ruch kanałowy z jednego edytora.",
"wizardMessagingFeature2": "Twórz aktualizacje SAR i wielokrotnego użytku szablony SAR do typowych raportów terenowych.",
"wizardMessagingFeature3": "Przesyłaj sesje głosowe i obrazy z postępem i szacowanym czasem transmisji w interfejsie.",
"wizardConnectDeviceTitle": "Połącz urządzenie",
"wizardConnectDeviceDescription": "Podłącz radio MeshCore, wybierz nazwę i zastosuj profil radiowy przed kontynuacją.",
"wizardSetupBadge": "Konfiguracja",
"wizardOverviewBadge": "Przegląd",
"wizardConnectedToDevice": "Połączono z {deviceName}",
"wizardNoDeviceConnected": "Brak podłączonego urządzenia",
"wizardSkipForNow": "Pomiń na razie",
"wizardDeviceNameLabel": "Nazwa urządzenia",
"wizardDeviceNameHelp": "Ta nazwa jest rozgłaszana innym użytkownikom MeshCore.",
"wizardConfigRegionLabel": "Region konfiguracji",
"wizardConfigRegionHelp": "Używa pełnej oficjalnej listy presetów MeshCore. Domyślnie wybrane jest EU/UK (Narrow).",
"wizardPresetNote1": "Upewnij się, że wybrany preset jest zgodny z lokalnymi przepisami radiowymi.",
"wizardPresetNote2": "Lista odpowiada oficjalnemu źródłu presetów narzędzia konfiguracji MeshCore.",
"wizardPresetNote3": "EU/UK (Narrow) pozostaje domyślnie wybrane podczas wdrożenia.",
"wizardSaving": "Zapisywanie...",
"wizardSaveAndContinue": "Zapisz i kontynuuj",
"wizardEnterDeviceName": "Wprowadź nazwę urządzenia przed kontynuacją.",
"wizardDeviceSetupSaved": "Zapisano {deviceName} z ustawieniem {presetName}.",
"wizardNetworkTitle": "Kontakty, pokoje i repeatery",
"wizardNetworkDescription": "Karta Kontakty porządkuje sieć, którą odkrywasz, oraz trasy, których uczysz się z czasem.",
"wizardNetworkFeature1": "Przeglądaj członków zespołu, repeatery, pokoje, kanały i oczekujące ogłoszenia na jednej liście.",
"wizardNetworkFeature2": "Używaj smart ping, logowania do pokoi, poznanych ścieżek i narzędzi resetowania tras, gdy łączność staje się chaotyczna.",
"wizardNetworkFeature3": "Twórz kanały i zarządzaj celami sieciowymi bez opuszczania aplikacji.",
"wizardMapOpsTitle": "Mapa, ślady i współdzielona geometria",
"wizardMapOpsDescription": "Mapa aplikacji jest bezpośrednio powiązana z wiadomościami, śledzeniem i nakładkami SAR zamiast być osobnym podglądem.",
"wizardMapOpsFeature1": "Śledź własną pozycję, lokalizacje członków zespołu i ślady ruchu na mapie.",
"wizardMapOpsFeature2": "Otwieraj rysunki z wiadomości, podglądaj je w miejscu i usuwaj z mapy w razie potrzeby.",
"wizardMapOpsFeature3": "Korzystaj z widoków map repeaterów i współdzielonych nakładek, aby zrozumieć zasięg sieci w terenie.",
"wizardToolsTitle": "Narzędzia poza wiadomościami",
"wizardToolsDescription": "Jest tu więcej niż cztery główne karty. Aplikacja obejmuje także konfigurację, diagnostykę i opcjonalne przepływy czujników.",
"wizardToolsFeature1": "Otwórz konfigurację urządzenia, aby zmienić ustawienia radia, telemetrię, moc TX i szczegóły urządzenia towarzyszącego.",
"wizardToolsFeature2": "Włącz kartę Czujniki, gdy chcesz mieć pulpity monitorowanych czujników i szybkie odświeżanie.",
"wizardToolsFeature3": "Używaj logów pakietów, skanowania widma i diagnostyki deweloperskiej podczas rozwiązywania problemów z mesh.",
"contactInSensors": "W Czujnikach",
"contactAddToSensors": "Dodaj do Czujników",
"contactSetPath": "Ustaw ścieżkę",
"contactAddedToSensors": "{contactName} dodano do Czujników",
"contactFailedToClearRoute": "Nie udało się wyczyścić trasy: {error}",
"contactRouteCleared": "Trasa wyczyszczona",
"contactRouteSet": "Ustawiono trasę: {route}",
"contactFailedToSetRoute": "Nie udało się ustawić trasy: {error}"
} }

View File

@@ -2276,5 +2276,57 @@
"voiceUnavailable": "A voz não está disponível no momento", "voiceUnavailable": "A voz não está disponível no momento",
"@voiceUnavailable": {}, "@voiceUnavailable": {},
"requestingVoice": "Solicitando voz", "requestingVoice": "Solicitando voz",
"@requestingVoice": {} "@requestingVoice": {},
"device": "dispositivo",
"change": "Alterar",
"wizardOverviewDescription": "Este aplicativo reúne mensagens MeshCore, atualizações SAR em campo, mapas e ferramentas do dispositivo em um só lugar.",
"wizardOverviewFeature1": "Envie mensagens diretas, publicações em salas e mensagens de canal pela guia principal Mensagens.",
"wizardOverviewFeature2": "Compartilhe marcadores SAR, desenhos de mapa, clipes de voz e imagens pela malha.",
"wizardOverviewFeature3": "Conecte-se via BLE ou TCP e depois gerencie o rádio complementar dentro do aplicativo.",
"wizardMessagingTitle": "Mensagens e relatórios de campo",
"wizardMessagingDescription": "Aqui, as mensagens são mais do que texto simples. O aplicativo já oferece suporte a várias cargas operacionais e fluxos de transferência.",
"wizardMessagingFeature1": "Envie mensagens diretas, publicações em salas e tráfego de canal a partir de um único compositor.",
"wizardMessagingFeature2": "Crie atualizações SAR e modelos SAR reutilizáveis para relatórios de campo comuns.",
"wizardMessagingFeature3": "Transfira sessões de voz e imagens com progresso e estimativas de tempo de transmissão na interface.",
"wizardConnectDeviceTitle": "Conectar dispositivo",
"wizardConnectDeviceDescription": "Conecte seu rádio MeshCore, escolha um nome e aplique uma predefinição de rádio antes de continuar.",
"wizardSetupBadge": "Configuração",
"wizardOverviewBadge": "Visão geral",
"wizardConnectedToDevice": "Conectado a {deviceName}",
"wizardNoDeviceConnected": "Nenhum dispositivo conectado ainda",
"wizardSkipForNow": "Ignorar por enquanto",
"wizardDeviceNameLabel": "Nome do dispositivo",
"wizardDeviceNameHelp": "Esse nome é anunciado para outros usuários do MeshCore.",
"wizardConfigRegionLabel": "Região de configuração",
"wizardConfigRegionHelp": "Usa a lista oficial completa de predefinições do MeshCore. O padrão é EU/UK (Narrow).",
"wizardPresetNote1": "Certifique-se de que a predefinição selecionada corresponda às regulamentações locais de rádio.",
"wizardPresetNote2": "A lista corresponde ao feed oficial de predefinições da ferramenta de configuração do MeshCore.",
"wizardPresetNote3": "EU/UK (Narrow) permanece selecionado por padrão durante a configuração inicial.",
"wizardSaving": "Salvando...",
"wizardSaveAndContinue": "Salvar e continuar",
"wizardEnterDeviceName": "Digite um nome para o dispositivo antes de continuar.",
"wizardDeviceSetupSaved": "{deviceName} salvo com {presetName}.",
"wizardNetworkTitle": "Contatos, salas e repetidores",
"wizardNetworkDescription": "A guia Contatos organiza a rede que você descobre e as rotas aprendidas ao longo do tempo.",
"wizardNetworkFeature1": "Revise membros da equipe, repetidores, salas, canais e anúncios pendentes em uma única lista.",
"wizardNetworkFeature2": "Use ping inteligente, login em salas, caminhos aprendidos e ferramentas de redefinição de rota quando a conectividade ficar confusa.",
"wizardNetworkFeature3": "Crie canais e gerencie destinos de rede sem sair do aplicativo.",
"wizardMapOpsTitle": "Mapa, trilhas e geometria compartilhada",
"wizardMapOpsDescription": "O mapa do aplicativo está ligado diretamente às mensagens, ao rastreamento e às sobreposições SAR, em vez de ser um visualizador separado.",
"wizardMapOpsFeature1": "Acompanhe sua própria posição, as localizações da equipe e as trilhas de movimento no mapa.",
"wizardMapOpsFeature2": "Abra desenhos de mensagens, visualize-os em linha e remova-os do mapa quando necessário.",
"wizardMapOpsFeature3": "Use vistas de mapa de repetidores e sobreposições compartilhadas para entender o alcance da rede em campo.",
"wizardToolsTitle": "Ferramentas além das mensagens",
"wizardToolsDescription": "Há mais aqui do que as quatro guias principais. O aplicativo também inclui configuração, diagnóstico e fluxos opcionais de sensores.",
"wizardToolsFeature1": "Abra a configuração do dispositivo para alterar ajustes de rádio, telemetria, potência TX e detalhes do equipamento complementar.",
"wizardToolsFeature2": "Ative a guia Sensores quando quiser painéis monitorados e ações de atualização rápida.",
"wizardToolsFeature3": "Use logs de pacotes, varredura de espectro e diagnóstico de desenvolvedor ao solucionar problemas da malha.",
"contactInSensors": "Nos Sensores",
"contactAddToSensors": "Adicionar aos Sensores",
"contactSetPath": "Definir rota",
"contactAddedToSensors": "{contactName} adicionado a Sensores",
"contactFailedToClearRoute": "Falha ao limpar a rota: {error}",
"contactRouteCleared": "Rota limpa",
"contactRouteSet": "Rota definida: {route}",
"contactFailedToSetRoute": "Falha ao definir a rota: {error}"
} }

View File

@@ -511,5 +511,57 @@
"rawDump": "Необработанные данные", "rawDump": "Необработанные данные",
"cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе", "cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе",
"voiceUnavailable": "Голос сейчас недоступен", "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}"
} }

View File

@@ -512,4 +512,57 @@
"cannotRetryMissingRecipient": "Ponovnega pošiljanja ni mogoče: informacije o prejemniku manjkajo", "cannotRetryMissingRecipient": "Ponovnega pošiljanja ni mogoče: informacije o prejemniku manjkajo",
"voiceUnavailable": "Glas trenutno ni na voljo", "voiceUnavailable": "Glas trenutno ni na voljo",
"requestingVoice": "Zahteva za glasom" "requestingVoice": "Zahteva za glasom"
,
"device": "naprava",
"change": "Spremeni",
"wizardOverviewDescription": "Ta aplikacija združuje sporočanje MeshCore, terenske SAR posodobitve, zemljevide in orodja za napravo na enem mestu.",
"wizardOverviewFeature1": "Pošiljajte neposredna sporočila, objave v sobah in sporočila na kanalih iz glavnega zavihka Sporočila.",
"wizardOverviewFeature2": "Delite SAR označevalce, risbe zemljevida, glasovne posnetke in slike prek mesh omrežja.",
"wizardOverviewFeature3": "Povežite se prek BLE ali TCP in nato upravljajte spremljevalni radio kar iz aplikacije.",
"wizardMessagingTitle": "Sporočanje in terenska poročila",
"wizardMessagingDescription": "Sporočila tukaj niso le navadno besedilo. Aplikacija že podpira več operativnih vrst vsebin in prenosnih tokov.",
"wizardMessagingFeature1": "Pošiljajte neposredna sporočila, objave v sobah in promet na kanalih iz enega urejevalnika.",
"wizardMessagingFeature2": "Ustvarjajte SAR posodobitve in večkrat uporabne SAR predloge za pogosta terenska poročila.",
"wizardMessagingFeature3": "Prenašajte glasovne seje in slike, vmesnik pa prikazuje napredek in oceno časa prenosa.",
"wizardConnectDeviceTitle": "Poveži napravo",
"wizardConnectDeviceDescription": "Povežite svoj MeshCore radio, izberite ime in uporabite radijski prednastavljeni profil, preden nadaljujete.",
"wizardSetupBadge": "Nastavitev",
"wizardOverviewBadge": "Pregled",
"wizardConnectedToDevice": "Povezano z: {deviceName}",
"wizardNoDeviceConnected": "Nobena naprava še ni povezana",
"wizardSkipForNow": "Za zdaj preskoči",
"wizardDeviceNameLabel": "Ime naprave",
"wizardDeviceNameHelp": "To ime se oglašuje drugim uporabnikom MeshCore.",
"wizardConfigRegionLabel": "Konfiguracijska regija",
"wizardConfigRegionHelp": "Uporabi celoten uradni seznam prednastavitev MeshCore. Privzeto je izbran EU/UK (Narrow).",
"wizardPresetNote1": "Prepričajte se, da izbrana prednastavitev ustreza lokalnim radijskim predpisom.",
"wizardPresetNote2": "Seznam ustreza uradnemu viru prednastavitev orodja MeshCore config.",
"wizardPresetNote3": "Za uvajanje ostane privzeto izbran EU/UK (Narrow).",
"wizardSaving": "Shranjujem...",
"wizardSaveAndContinue": "Shrani in nadaljuj",
"wizardEnterDeviceName": "Pred nadaljevanjem vnesite ime naprave.",
"wizardDeviceSetupSaved": "Shranjeno {deviceName} z nastavitvijo {presetName}.",
"wizardNetworkTitle": "Stiki, sobe in repetitorji",
"wizardNetworkDescription": "Zavihek Stiki organizira omrežje, ki ga odkrijete, in poti, ki se jih aplikacija nauči skozi čas.",
"wizardNetworkFeature1": "Na enem seznamu preglejte člane ekipe, repetitorje, sobe, kanale in čakajoče oglase.",
"wizardNetworkFeature2": "Uporabite smart ping, prijavo v sobe, naučene poti in ponastavitev poti, ko povezljivost postane neurejena.",
"wizardNetworkFeature3": "Ustvarjajte kanale in upravljajte omrežne cilje, ne da bi zapustili aplikacijo.",
"wizardMapOpsTitle": "Zemljevid, sledi in deljena geometrija",
"wizardMapOpsDescription": "Zemljevid v aplikaciji je neposredno povezan s sporočanjem, sledenjem in SAR prekrivanji, namesto da bi bil le ločen pregledovalnik.",
"wizardMapOpsFeature1": "Spremljajte svoj položaj, lokacije sotekmovalcev in gibanje sledi na zemljevidu.",
"wizardMapOpsFeature2": "Odprite risbe iz sporočil, jih predoglejte v vrstici in jih po potrebi odstranite z zemljevida.",
"wizardMapOpsFeature3": "Uporabite poglede repetitorjev in deljena prekrivanja za razumevanje dosega omrežja na terenu.",
"wizardToolsTitle": "Orodja onkraj sporočanja",
"wizardToolsDescription": "Tu je več kot le štirje glavni zavihki. Aplikacija vključuje tudi konfiguracijo, diagnostiko in neobvezne senzorske poteke.",
"wizardToolsFeature1": "Odprite nastavitve naprave za spremembo radijskih nastavitev, telemetrije, oddajne moči in podatkov spremljevalne naprave.",
"wizardToolsFeature2": "Omogočite zavihek Senzorji, kadar želite nadzorne plošče in hitra osveževanja spremljanih senzorjev.",
"wizardToolsFeature3": "Uporabite dnevnik paketov, pregled spektra in razvojno diagnostiko pri odpravljanju težav v mesh omrežju.",
"contactInSensors": "V senzorjih",
"contactAddToSensors": "Dodaj v senzorje",
"contactSetPath": "Nastavi pot",
"contactAddedToSensors": "{contactName} dodan v senzorje",
"contactFailedToClearRoute": "Brisanje poti ni uspelo: {error}",
"contactRouteCleared": "Pot izbrisana",
"contactRouteSet": "Pot nastavljena: {route}",
"contactFailedToSetRoute": "Nastavitev poti ni uspela: {error}"
} }

View File

@@ -2276,5 +2276,57 @@
"voiceUnavailable": "Ses şu anda kullanılamıyor", "voiceUnavailable": "Ses şu anda kullanılamıyor",
"@voiceUnavailable": {}, "@voiceUnavailable": {},
"requestingVoice": "Ses isteniyor", "requestingVoice": "Ses isteniyor",
"@requestingVoice": {} "@requestingVoice": {},
"device": "cihaz",
"change": "Değiştir",
"wizardOverviewDescription": "Bu uygulama MeshCore mesajlaşmasını, SAR saha güncellemelerini, haritalamayı ve cihaz araçlarını tek bir yerde birleştirir.",
"wizardOverviewFeature1": "Ana Mesajlar sekmesinden doğrudan mesajlar, oda gönderileri ve kanal mesajları gönderin.",
"wizardOverviewFeature2": "Mesh üzerinden SAR işaretleri, harita çizimleri, ses klipleri ve görseller paylaşın.",
"wizardOverviewFeature3": "BLE veya TCP ile bağlanın, ardından yardımcı radyoyu uygulamanın içinden yönetin.",
"wizardMessagingTitle": "Mesajlaşma ve saha raporları",
"wizardMessagingDescription": "Buradaki mesajlar düz metinden fazlasıdır. Uygulama zaten çeşitli operasyonel yükleri ve aktarım akışlarını destekliyor.",
"wizardMessagingFeature1": "Doğrudan mesajları, oda gönderilerini ve kanal trafiğini tek bir düzenleyiciden gönderin.",
"wizardMessagingFeature2": "Yaygın saha raporları için SAR güncellemeleri ve yeniden kullanılabilir SAR şablonları oluşturun.",
"wizardMessagingFeature3": "Arayüzde ilerleme ve yayın süresi tahminleriyle ses oturumları ve görseller aktarın.",
"wizardConnectDeviceTitle": "Cihazı bağla",
"wizardConnectDeviceDescription": "MeshCore radyonuzu bağlayın, bir ad seçin ve devam etmeden önce bir radyo ön ayarı uygulayın.",
"wizardSetupBadge": "Kurulum",
"wizardOverviewBadge": "Genel bakış",
"wizardConnectedToDevice": "{deviceName} cihazına bağlandı",
"wizardNoDeviceConnected": "Henüz bağlı cihaz yok",
"wizardSkipForNow": "Şimdilik atla",
"wizardDeviceNameLabel": "Cihaz adı",
"wizardDeviceNameHelp": "Bu ad diğer MeshCore kullanıcılarına duyurulur.",
"wizardConfigRegionLabel": "Yapılandırma bölgesi",
"wizardConfigRegionHelp": "MeshCore'un resmi ön ayar listesinin tamamını kullanır. Varsayılan EU/UK (Narrow) olur.",
"wizardPresetNote1": "Seçilen ön ayarın yerel radyo düzenlemelerinize uygun olduğundan emin olun.",
"wizardPresetNote2": "Liste, MeshCore yapılandırma aracının resmi ön ayar akışıyla eşleşir.",
"wizardPresetNote3": "Onboarding sırasında varsayılan olarak EU/UK (Narrow) seçili kalır.",
"wizardSaving": "Kaydediliyor...",
"wizardSaveAndContinue": "Kaydet ve devam et",
"wizardEnterDeviceName": "Devam etmeden önce bir cihaz adı girin.",
"wizardDeviceSetupSaved": "{deviceName}, {presetName} ile kaydedildi.",
"wizardNetworkTitle": "Kişiler, odalar ve tekrarlayıcılar",
"wizardNetworkDescription": "Kişiler sekmesi keşfettiğiniz ağı ve zamanla öğrendiğiniz rotaları düzenler.",
"wizardNetworkFeature1": "Ekip üyelerini, tekrarlayıcıları, odaları, kanalları ve bekleyen duyuruları tek listede inceleyin.",
"wizardNetworkFeature2": "Bağlantı karmaşıklaştığında smart ping, oda girişi, öğrenilen yollar ve rota sıfırlama araçlarını kullanın.",
"wizardNetworkFeature3": "Uygulamadan çıkmadan kanallar oluşturun ve ağ hedeflerini yönetin.",
"wizardMapOpsTitle": "Harita, izler ve paylaşılan geometri",
"wizardMapOpsDescription": "Uygulama haritası ayrı bir görüntüleyici olmak yerine mesajlaşma, takip ve SAR katmanlarıyla doğrudan bağlantılıdır.",
"wizardMapOpsFeature1": "Kendi konumunuzu, ekip arkadaşlarının konumlarını ve hareket izlerini harita üzerinde takip edin.",
"wizardMapOpsFeature2": "Mesajlardaki çizimleri açın, satır içinde önizleyin ve gerektiğinde haritadan kaldırın.",
"wizardMapOpsFeature3": "Sahadaki ağ kapsamasını anlamak için tekrarlayıcı harita görünümleri ve paylaşılan katmanları kullanın.",
"wizardToolsTitle": "Mesajlaşma dışındaki araçlar",
"wizardToolsDescription": "Burada dört ana sekmeden fazlası var. Uygulama ayrıca yapılandırma, tanılama ve isteğe bağlı sensör akışları içerir.",
"wizardToolsFeature1": "Radyo ayarlarını, telemetriyi, TX gücünü ve yardımcı cihaz ayrıntılarını değiştirmek için cihaz yapılandırmasınıın.",
"wizardToolsFeature2": "İzlenen sensör panoları ve hızlı yenileme eylemleri istediğinizde Sensörler sekmesini etkinleştirin.",
"wizardToolsFeature3": "Mesh sorunlarını giderirken paket günlükleri, spektrum taraması ve geliştirici tanılamalarını kullanın.",
"contactInSensors": "Sensörlerde",
"contactAddToSensors": "Sensörlere ekle",
"contactSetPath": "Yol ayarla",
"contactAddedToSensors": "{contactName} Sensörlere eklendi",
"contactFailedToClearRoute": "Rota temizlenemedi: {error}",
"contactRouteCleared": "Rota temizlendi",
"contactRouteSet": "Rota ayarlandı: {route}",
"contactFailedToSetRoute": "Rota ayarlanamadı: {error}"
} }

View File

@@ -2276,5 +2276,57 @@
"voiceUnavailable": "Голос зараз недоступний", "voiceUnavailable": "Голос зараз недоступний",
"@voiceUnavailable": {}, "@voiceUnavailable": {},
"requestingVoice": "Запит голосу", "requestingVoice": "Запит голосу",
"@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}"
} }

View File

@@ -2301,5 +2301,57 @@
}, },
"requestingVoice": "正在请求语音", "requestingVoice": "正在请求语音",
"@requestingVoice": { "@requestingVoice": {
} },
"device": "设备",
"change": "更改",
"wizardOverviewDescription": "此应用将 MeshCore 消息、SAR 现场更新、地图功能和设备工具整合到同一处。",
"wizardOverviewFeature1": "在主消息标签页中发送私聊消息、房间消息和频道消息。",
"wizardOverviewFeature2": "通过 mesh 网络共享 SAR 标记、地图绘图、语音片段和图像。",
"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 配置工具的官方预设源保持一致。",
"wizardPresetNote3": "在引导设置中默认保持选中 EU/UK (Narrow)。",
"wizardSaving": "正在保存...",
"wizardSaveAndContinue": "保存并继续",
"wizardEnterDeviceName": "继续前请输入设备名称。",
"wizardDeviceSetupSaved": "已保存 {deviceName},配置为 {presetName}。",
"wizardNetworkTitle": "联系人、房间和中继器",
"wizardNetworkDescription": "联系人标签页会整理你发现的网络以及随着时间学习到的路由。",
"wizardNetworkFeature1": "在一个列表中查看团队成员、中继器、房间、频道和待处理广播。",
"wizardNetworkFeature2": "当连接变得混乱时,使用智能 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}"
} }

View File

@@ -25,6 +25,7 @@ import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart'; import 'services/mesh_map_nodes_service.dart';
import 'services/update_checker_service.dart'; import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart'; import 'services/wizard_preferences.dart';
import 'screens/discovery_screen.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
import 'screens/welcome_wizard_screen.dart'; import 'screens/welcome_wizard_screen.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
@@ -42,11 +43,13 @@ class MeshCoreSarApp extends StatefulWidget {
} }
class _MeshCoreSarAppState extends State<MeshCoreSarApp> { class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
AppThemeMode _themeMode = AppThemeMode.system; AppThemeMode _themeMode = AppThemeMode.system;
Locale? _locale; Locale? _locale;
bool _isInitialized = false; bool _isInitialized = false;
bool _shouldShowPermissionDialog = false; bool _shouldShowPermissionDialog = false;
bool _wizardCompleted = true; // Will be updated in _initializeApp() bool _wizardCompleted = true; // Will be updated in _initializeApp()
String? _pendingNotificationPayload;
@override @override
void initState() { void initState() {
@@ -66,6 +69,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Set up notification tap handler for update notifications // Set up notification tap handler for update notifications
NotificationService().onNotificationTapped = _handleNotificationTap; NotificationService().onNotificationTapped = _handleNotificationTap;
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
// Check if we need to request location permissions // Check if we need to request location permissions
await _checkLocationPermissions(); await _checkLocationPermissions();
@@ -81,6 +85,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
_wizardCompleted = wizardCompleted; _wizardCompleted = wizardCompleted;
_isInitialized = true; _isInitialized = true;
}); });
if (_pendingNotificationPayload != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final payload = _pendingNotificationPayload;
_pendingNotificationPayload = null;
_handleNotificationTap(payload);
});
}
} }
/// Handle notification tap /// Handle notification tap
@@ -89,10 +101,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
debugPrint('[Main] Notification tapped: $payload'); debugPrint('[Main] Notification tapped: $payload');
if (!_wizardCompleted) {
_pendingNotificationPayload = payload;
return;
}
// Handle update notification tap // Handle update notification tap
if (payload.startsWith('update:')) { if (payload.startsWith('update:')) {
final downloadUrl = payload.substring(7); // Remove 'update:' prefix final downloadUrl = payload.substring(7); // Remove 'update:' prefix
_launchUpdateDownload(downloadUrl); _launchUpdateDownload(downloadUrl);
return;
}
if (payload.startsWith('discovery:')) {
final navigator = _navigatorKey.currentState;
if (navigator == null) {
_pendingNotificationPayload = payload;
return;
}
navigator.push(
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
);
} }
// SAR and message notifications handled by their respective providers // SAR and message notifications handled by their respective providers
} }
@@ -304,6 +333,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
builder: (context) { builder: (context) {
final systemBrightness = MediaQuery.platformBrightnessOf(context); final systemBrightness = MediaQuery.platformBrightnessOf(context);
final materialApp = MaterialApp( final materialApp = MaterialApp(
navigatorKey: _navigatorKey,
key: ValueKey<String?>( key: ValueKey<String?>(
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}', '${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
), ),

View File

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

View File

@@ -14,9 +14,6 @@ enum ConnectionMode {
/// Direct BLE connection to MeshCore device (default) /// Direct BLE connection to MeshCore device (default)
ble, ble,
/// Act as SSE server - share BLE device with multiple clients
sseServer,
/// Direct TCP/WiFi connection to MeshCore device (port 5000) /// Direct TCP/WiFi connection to MeshCore device (port 5000)
tcp, tcp,
} }
@@ -26,8 +23,6 @@ extension ConnectionModeExtension on ConnectionMode {
switch (this) { switch (this) {
case ConnectionMode.ble: case ConnectionMode.ble:
return 'Direct (BLE)'; return 'Direct (BLE)';
case ConnectionMode.sseServer:
return 'Share Device (Server)';
case ConnectionMode.tcp: case ConnectionMode.tcp:
return 'Direct (WiFi)'; return 'Direct (WiFi)';
} }
@@ -37,8 +32,6 @@ extension ConnectionModeExtension on ConnectionMode {
switch (this) { switch (this) {
case ConnectionMode.ble: case ConnectionMode.ble:
return 'Direct BLE connection to MeshCore device'; return 'Direct BLE connection to MeshCore device';
case ConnectionMode.sseServer:
return 'Share BLE device with multiple clients over network';
case ConnectionMode.tcp: case ConnectionMode.tcp:
return 'Direct WiFi/TCP connection to MeshCore device'; return 'Direct WiFi/TCP connection to MeshCore device';
} }
@@ -66,6 +59,11 @@ class DeviceInfo {
final int? advLat; final int? advLat;
final int? advLon; final int? advLon;
final bool? manualAddContacts; final bool? manualAddContacts;
final bool? autoAddUsers;
final bool? autoAddRepeaters;
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final int? radioFreq; final int? radioFreq;
final int? radioBw; final int? radioBw;
final int? radioSf; final int? radioSf;
@@ -113,6 +111,11 @@ class DeviceInfo {
this.advLat, this.advLat,
this.advLon, this.advLon,
this.manualAddContacts, this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.radioFreq, this.radioFreq,
this.radioBw, this.radioBw,
this.radioSf, this.radioSf,
@@ -239,6 +242,11 @@ class DeviceInfo {
int? advLat, int? advLat,
int? advLon, int? advLon,
bool? manualAddContacts, bool? manualAddContacts,
bool? autoAddUsers,
bool? autoAddRepeaters,
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? radioFreq, int? radioFreq,
int? radioBw, int? radioBw,
int? radioSf, int? radioSf,
@@ -278,6 +286,12 @@ class DeviceInfo {
advLat: advLat ?? this.advLat, advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon, advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts, manualAddContacts: manualAddContacts ?? this.manualAddContacts,
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
autoAddOverwriteOldest:
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
radioFreq: radioFreq ?? this.radioFreq, radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw, radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf, radioSf: radioSf ?? this.radioSf,

View File

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

View File

@@ -1,7 +1,10 @@
enum PathRecordSource { learned, observed }
class PathRecord { class PathRecord {
final List<int> pathBytes; final List<int> pathBytes;
final int hopCount; final int hopCount;
final int hashSize; final int hashSize;
final PathRecordSource source;
final int successCount; final int successCount;
final int failureCount; final int failureCount;
final int lastRoundTripTimeMs; final int lastRoundTripTimeMs;
@@ -11,6 +14,7 @@ class PathRecord {
required this.pathBytes, required this.pathBytes,
required this.hopCount, required this.hopCount,
required this.hashSize, required this.hashSize,
required this.source,
required this.successCount, required this.successCount,
required this.failureCount, required this.failureCount,
required this.lastRoundTripTimeMs, required this.lastRoundTripTimeMs,
@@ -27,6 +31,7 @@ class PathRecord {
List<int>? pathBytes, List<int>? pathBytes,
int? hopCount, int? hopCount,
int? hashSize, int? hashSize,
PathRecordSource? source,
int? successCount, int? successCount,
int? failureCount, int? failureCount,
int? lastRoundTripTimeMs, int? lastRoundTripTimeMs,
@@ -36,6 +41,7 @@ class PathRecord {
pathBytes: pathBytes ?? this.pathBytes, pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount, hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize, hashSize: hashSize ?? this.hashSize,
source: source ?? this.source,
successCount: successCount ?? this.successCount, successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount, failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs, lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
@@ -48,6 +54,7 @@ class PathRecord {
'path_bytes': pathBytes, 'path_bytes': pathBytes,
'hop_count': hopCount, 'hop_count': hopCount,
'hash_size': hashSize, 'hash_size': hashSize,
'source': source.name,
'success_count': successCount, 'success_count': successCount,
'failure_count': failureCount, 'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs, 'last_round_trip_time_ms': lastRoundTripTimeMs,
@@ -62,6 +69,10 @@ class PathRecord {
.toList(), .toList(),
hopCount: json['hop_count'] as int? ?? 0, hopCount: json['hop_count'] as int? ?? 0,
hashSize: json['hash_size'] as int? ?? 1, 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, successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0, failureCount: json['failure_count'] as int? ?? 0,
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0, lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
@@ -163,6 +174,10 @@ class ContactPathHistory {
}; };
} }
List<PathRecord> get observedPaths => directPaths
.where((record) => record.source == PathRecordSource.observed)
.toList();
factory ContactPathHistory.fromJson( factory ContactPathHistory.fromJson(
String contactPublicKeyHex, String contactPublicKeyHex,
Map<String, dynamic> json, Map<String, dynamic> json,

View File

@@ -1,71 +0,0 @@
/// SSE Server Configuration Model
///
/// Configuration for the SSE (Server-Sent Events) web server that enables
/// multiple app instances to share a single MeshCore BLE device.
class SseServerConfig {
/// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost)
final String host;
/// Server port (default: 12929)
final int port;
/// Whether the SSE server is enabled
final bool enabled;
/// Optional authentication token for basic security
/// Clients must include this token in Authorization header
final String? authToken;
const SseServerConfig({
this.host = '0.0.0.0',
this.port = 12929,
this.enabled = false,
this.authToken,
});
/// Create a copy with updated fields
SseServerConfig copyWith({
String? host,
int? port,
bool? enabled,
String? authToken,
}) {
return SseServerConfig(
host: host ?? this.host,
port: port ?? this.port,
enabled: enabled ?? this.enabled,
authToken: authToken ?? this.authToken,
);
}
/// Get server URL for clients to connect to
String getServerUrl({String? ipAddress}) {
final ip = ipAddress ?? host;
return 'http://$ip:$port';
}
/// Convert to JSON for persistence
Map<String, dynamic> toJson() {
return {
'host': host,
'port': port,
'enabled': enabled,
'authToken': authToken,
};
}
/// Create from JSON
factory SseServerConfig.fromJson(Map<String, dynamic> json) {
return SseServerConfig(
host: json['host'] as String? ?? '0.0.0.0',
port: json['port'] as int? ?? 12929,
enabled: json['enabled'] as bool? ?? false,
authToken: json['authToken'] as String?,
);
}
@override
String toString() {
return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})';
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -18,13 +18,20 @@ import '../utils/image_message_parser.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import 'helpers/message_retry_manager.dart'; import 'helpers/message_retry_manager.dart';
typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
/// Messages Provider - manages message history and SAR markers /// Messages Provider - manages message history and SAR markers
class MessagesProvider with ChangeNotifier { class MessagesProvider with ChangeNotifier {
static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
final List<Message> _messages = []; final List<Message> _messages = [];
final Map<String, SarMarker> _sarMarkers = {}; final Map<String, SarMarker> _sarMarkers = {};
final Set<String> _removedSarMarkerIds = <String>{};
final MessageStorageService _storageService = MessageStorageService(); final MessageStorageService _storageService = MessageStorageService();
final NotificationService _notificationService = NotificationService(); final NotificationService _notificationService = NotificationService();
bool _isInitialized = false; bool _isInitialized = false;
bool _isPersisting = false;
bool _persistRequested = false;
AppLocalizations? _localizations; AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {}; final Map<String, MessageContactLocation> _messageContactLocations = {};
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {}; final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
@@ -37,6 +44,8 @@ class MessagesProvider with ChangeNotifier {
// Track timeout timers for pending messages // Track timeout timers for pending messages
// Key: message ID (not ACK tag, since multiple messages can share same ACK) // Key: message ID (not ACK tag, since multiple messages can share same ACK)
final Map<String, Timer> _timeoutTimers = {}; final Map<String, Timer> _timeoutTimers = {};
final Map<String, Timer> _channelEchoWarningTimers = {};
final Set<String> _channelEchoWarningMessageIds = <String>{};
// Recently completed ACKs are kept briefly to ignore duplicate confirms. // Recently completed ACKs are kept briefly to ignore duplicate confirms.
final Map<int, DateTime> _completedAckHistory = {}; final Map<int, DateTime> _completedAckHistory = {};
@@ -86,6 +95,7 @@ class MessagesProvider with ChangeNotifier {
Future<void> Function({required Contact contact, required int failureStreak})? Future<void> Function({required Contact contact, required int failureStreak})?
onDirectPathFailedCallback; onDirectPathFailedCallback;
void Function(String messageId)? onManualRetryPreparedCallback;
Future<bool> Function({ Future<bool> Function({
required String messageId, required String messageId,
required Contact contact, required Contact contact,
@@ -124,6 +134,7 @@ class MessagesProvider with ChangeNotifier {
_messages.where((m) => m.isSystemMessage).toList(); _messages.where((m) => m.isSystemMessage).toList();
List<SarMarker> get sarMarkers => _sarMarkers.values.toList(); List<SarMarker> get sarMarkers => _sarMarkers.values.toList();
Set<String> get removedSarMarkerIds => Set.unmodifiable(_removedSarMarkerIds);
List<SarMarker> get foundPersonMarkers => List<SarMarker> get foundPersonMarkers =>
sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList(); sarMarkers.where((m) => m.type == SarMarkerType.foundPerson).toList();
@@ -153,6 +164,9 @@ class MessagesProvider with ChangeNotifier {
MessageRouteMetadata? getMessageRouteMetadata(String messageId) => MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
_messageRouteMetadata[messageId]; _messageRouteMetadata[messageId];
bool hasChannelSendWarning(String messageId) =>
_channelEchoWarningMessageIds.contains(messageId);
void updateMessageRouteSelection( void updateMessageRouteSelection(
String messageId, String messageId,
PathSelection selection, { PathSelection selection, {
@@ -165,8 +179,12 @@ class MessagesProvider with ChangeNotifier {
final index = _messages.indexWhere((message) => message.id == messageId); final index = _messages.indexWhere((message) => message.id == messageId);
if (index != -1) { if (index != -1) {
final nextPathLen = selection.hopCount > 0
? selection.hopCount
: _messages[index].pathLen;
_messages[index] = _messages[index].copyWith( _messages[index] = _messages[index].copyWith(
usedFloodFallback: selection.usesFlood, usedFloodFallback: selection.usesFlood,
pathLen: nextPathLen,
); );
} }
@@ -213,6 +231,22 @@ class MessagesProvider with ChangeNotifier {
.where((message) => _isMessageForDestination(message, contact)) .where((message) => _isMessageForDestination(message, contact))
.length; .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 int getUnreadCountForDestination(Contact contact) => _messages
.where( .where(
(message) => (message) =>
@@ -223,6 +257,17 @@ class MessagesProvider with ChangeNotifier {
) )
.length; .length;
int getUnreadCountForChannel(int channelIdx) => _messages
.where(
(message) =>
message.isChannelMessage &&
(message.channelIdx ?? 0) == channelIdx &&
!message.isRead &&
!message.isSentMessage &&
!message.isSystemMessage,
)
.length;
bool _isMessageForDestination(Message message, Contact contact) { bool _isMessageForDestination(Message message, Contact contact) {
if (message.isSystemMessage) return false; if (message.isSystemMessage) return false;
@@ -277,6 +322,8 @@ class MessagesProvider with ChangeNotifier {
.loadMessageTransferDetails(); .loadMessageTransferDetails();
final storedRouteMetadata = await _storageService final storedRouteMetadata = await _storageService
.loadMessageRouteMetadata(); .loadMessageRouteMetadata();
final storedRemovedSarMarkerIds = await _storageService
.loadRemovedSarMarkerIds();
_messageContactLocations _messageContactLocations
..clear() ..clear()
..addAll(storedContactLocations); ..addAll(storedContactLocations);
@@ -289,6 +336,9 @@ class MessagesProvider with ChangeNotifier {
_messageRouteMetadata _messageRouteMetadata
..clear() ..clear()
..addAll(storedRouteMetadata); ..addAll(storedRouteMetadata);
_removedSarMarkerIds
..clear()
..addAll(storedRemovedSarMarkerIds);
// Add stored messages with enhancement to ensure SAR detection // Add stored messages with enhancement to ensure SAR detection
for (final message in storedMessages) { for (final message in storedMessages) {
@@ -336,7 +386,7 @@ class MessagesProvider with ChangeNotifier {
// Extract SAR markers // Extract SAR markers
if (enhancedMessage.isSarMarker) { if (enhancedMessage.isSarMarker) {
final marker = enhancedMessage.toSarMarker(); final marker = enhancedMessage.toSarMarker();
if (marker != null) { if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
_sarMarkers[marker.id] = marker; _sarMarkers[marker.id] = marker;
} }
} }
@@ -477,6 +527,7 @@ class MessagesProvider with ChangeNotifier {
); );
} }
} }
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
// For channel messages with sender name, try to link with contact // For channel messages with sender name, try to link with contact
Message finalMessage = enhancedMessage; Message finalMessage = enhancedMessage;
@@ -502,6 +553,15 @@ class MessagesProvider with ChangeNotifier {
} }
} }
final incomingPathBytes = receptionDetailsSnapshot?.pathBytes;
if (incomingPathBytes != null &&
incomingPathBytes.isNotEmpty &&
finalMessage.pathBytes == null) {
finalMessage = finalMessage.copyWith(
pathBytes: Uint8List.fromList(incomingPathBytes),
);
}
// Debug: Check if message is SAR // Debug: Check if message is SAR
if (message.text.startsWith('S:')) { if (message.text.startsWith('S:')) {
debugPrint( debugPrint(
@@ -516,29 +576,40 @@ class MessagesProvider with ChangeNotifier {
// - Mesh network retransmissions // - Mesh network retransmissions
// - Multiple paths in the network // - Multiple paths in the network
// - Syncing messages from device queue // - Syncing messages from device queue
if (_isDuplicate(finalMessage)) { final duplicateIndex = _findDuplicateMessageIndex(finalMessage);
if (duplicateIndex != -1) {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}', '⚠️ [MessagesProvider] Duplicate message detected, skipping: ${finalMessage.id}',
); );
debugPrint( debugPrint(
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...', ' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
); );
final existingIndex = _messages.indexWhere( final existingId = _messages[duplicateIndex].id;
(existing) => if (finalMessage.isChannelMessage &&
existing.messageType == finalMessage.messageType && !finalMessage.isSentMessage &&
existing.senderTimestamp == finalMessage.senderTimestamp && _messages[duplicateIndex].isSentMessage) {
existing.text == finalMessage.text, _clearChannelSendWarning(existingId);
); }
if (existingIndex != -1) {
final existingId = _messages[existingIndex].id;
if (contactLocationSnapshot != null) { if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot; _messageContactLocations[existingId] = contactLocationSnapshot;
} }
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
incoming: receptionDetailsSnapshot,
);
if (receptionDetailsSnapshot != null) { if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[existingId] = receptionDetailsSnapshot; final existingMessage = _messages[duplicateIndex];
final duplicatePathBytes = receptionDetailsSnapshot.pathBytes;
if (existingMessage.pathBytes == null &&
duplicatePathBytes != null &&
duplicatePathBytes.isNotEmpty) {
_messages[duplicateIndex] = existingMessage.copyWith(
pathBytes: Uint8List.fromList(duplicatePathBytes),
);
}
} }
_persistMessages(); _persistMessages();
}
return; // Skip duplicate return; // Skip duplicate
} }
@@ -553,7 +624,7 @@ class MessagesProvider with ChangeNotifier {
// If it's a SAR marker message, extract and store the marker // If it's a SAR marker message, extract and store the marker
if (finalMessage.isSarMarker) { if (finalMessage.isSarMarker) {
final marker = finalMessage.toSarMarker(); final marker = finalMessage.toSarMarker();
if (marker != null) { if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
_sarMarkers[marker.id] = marker; _sarMarkers[marker.id] = marker;
// Trigger urgent notification for received SAR messages (not sent by user) // Trigger urgent notification for received SAR messages (not sent by user)
@@ -577,50 +648,101 @@ class MessagesProvider with ChangeNotifier {
/// Messages are considered duplicates if they have: /// Messages are considered duplicates if they have:
/// 1. Same sender public key prefix (for contact messages) /// 1. Same sender public key prefix (for contact messages)
/// 2. Same channel index (for channel messages) /// 2. Same channel index (for channel messages)
/// 3. Same sender timestamp /// 3. Same text content
/// 4. Same text content
/// ///
/// Note: Sent messages (isSentMessage=true) are NEVER duplicates /// Note: Sent messages (isSentMessage=true) are NEVER duplicates
/// because they can be retried with different message IDs /// because they can be retried with different message IDs
bool _isDuplicate(Message message) { int _findDuplicateMessageIndex(Message message) {
// Sent messages (our own messages) should never be considered duplicates // Sent messages (our own messages) should never be considered duplicates
// They can be retried multiple times with different IDs // They can be retried multiple times with different IDs
if (message.isSentMessage) { if (message.isSentMessage) {
return false; return -1;
} }
return _messages.any((existing) { for (int index = 0; index < _messages.length; index++) {
// Check message type matches final existing = _messages[index];
if (!_matchesDuplicateScope(existing, message) ||
existing.text != message.text) {
continue;
}
if (message.isChannelMessage) {
debugPrint(
'🔁 [MessagesProvider] Channel duplicate match: '
'incoming(id=${message.id}, senderName=${message.senderName ?? "-"}, senderKey=${message.senderKeyShort ?? "-"}, pathLen=${message.pathLen}, ts=${message.senderTimestamp}) '
'existing(id=${existing.id}, sent=${existing.isSentMessage}, senderName=${existing.senderName ?? "-"}, senderKey=${existing.senderKeyShort ?? "-"}, pathLen=${existing.pathLen}, ts=${existing.senderTimestamp})',
);
}
return index;
}
return -1;
}
bool _matchesDuplicateScope(Message existing, Message message) {
if (existing.messageType != message.messageType) { if (existing.messageType != message.messageType) {
return false; return false;
} }
// Check sender matches
if (message.isContactMessage) { if (message.isContactMessage) {
// For contact messages, compare sender public key prefix return existing.senderKeyShort == message.senderKeyShort;
if (existing.senderKeyShort != message.senderKeyShort) {
return false;
} }
} else if (message.isChannelMessage) {
// For channel messages, compare channel index if (message.isChannelMessage) {
if (existing.channelIdx != message.channelIdx) { if (existing.channelIdx != message.channelIdx) {
return false; return false;
} }
}
// Check timestamp matches (sender timestamp is the unique identifier from the sender) final withinChannelRepeatWindow =
if (existing.senderTimestamp != message.senderTimestamp) { (existing.senderTimestamp - message.senderTimestamp).abs() <= 30;
return false;
}
// Check text content matches final existingSenderKey = existing.senderKeyShort;
if (existing.text != message.text) { final incomingSenderKey = message.senderKeyShort;
return false; if (existingSenderKey != null &&
} incomingSenderKey != null &&
existingSenderKey == incomingSenderKey &&
// All criteria match - this is a duplicate withinChannelRepeatWindow) {
return true; return true;
}); }
final existingSenderName = _normalizedResolvedSenderName(existing);
final incomingSenderName = _normalizedResolvedSenderName(message);
if (existing.isChannelMessage && message.isChannelMessage) {
debugPrint(
'🧪 [MessagesProvider] Channel dedupe check: '
'existing(id=${existing.id}, sent=${existing.isSentMessage}, senderName=${existing.senderName ?? "-"}, resolvedSender=${existingSenderName ?? "-"}, senderKey=${existing.senderKeyShort ?? "-"}, ts=${existing.senderTimestamp}, pathLen=${existing.pathLen}) '
'incoming(id=${message.id}, sent=${message.isSentMessage}, senderName=${message.senderName ?? "-"}, resolvedSender=${incomingSenderName ?? "-"}, senderKey=${message.senderKeyShort ?? "-"}, ts=${message.senderTimestamp}, pathLen=${message.pathLen}) '
'withinWindow=$withinChannelRepeatWindow',
);
}
if (existingSenderName != null &&
incomingSenderName != null &&
existingSenderName == incomingSenderName) {
if (!existing.isSentMessage && !message.isSentMessage) {
return withinChannelRepeatWindow;
}
if (!withinChannelRepeatWindow) {
return false;
}
// Mirror meshcore-open behavior for self-authored channel messages:
// zero-hop self replays are dropped earlier in AppProvider, while
// routed self replays should fold into the local sent bubble when the
// public sender name matches within a short window.
if (existing.isSentMessage &&
!message.isSentMessage &&
existingSenderName == incomingSenderName) {
return true;
}
return true;
}
return false;
}
// System messages and other types: never deduplicate by scope alone.
return false;
} }
/// Add multiple messages /// Add multiple messages
@@ -630,10 +752,42 @@ class MessagesProvider with ChangeNotifier {
for (final message in messages) { for (final message in messages) {
// Always enhance message with SAR parser to detect SAR markers // Always enhance message with SAR parser to detect SAR markers
final enhancedMessage = SarMessageParser.enhanceMessage(message); var enhancedMessage = SarMessageParser.enhanceMessage(message);
// Check if it's a drawing message (D:...) and not already marked
if (DrawingMessageParser.isDrawingMessage(enhancedMessage.text) &&
!enhancedMessage.isDrawing) {
final drawing = DrawingMessageParser.parseDrawingMessage(
enhancedMessage.text,
senderName: enhancedMessage.senderName,
messageId: enhancedMessage.id,
);
enhancedMessage = enhancedMessage.copyWith(
isDrawing: true,
drawingId: drawing?.id,
);
}
// Check if it's a voice message and not already marked
if (!enhancedMessage.isVoice) {
final envelope = VoiceEnvelope.tryParseText(enhancedMessage.text);
if (envelope != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: envelope.sessionId,
);
}
}
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
// Check for duplicates // Check for duplicates
if (_isDuplicate(enhancedMessage)) { final duplicateIndex = _findDuplicateMessageIndex(enhancedMessage);
if (duplicateIndex != -1) {
final existingId = _messages[duplicateIndex].id;
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
);
duplicateCount++; duplicateCount++;
continue; // Skip duplicate continue; // Skip duplicate
} }
@@ -643,7 +797,7 @@ class MessagesProvider with ChangeNotifier {
if (enhancedMessage.isSarMarker) { if (enhancedMessage.isSarMarker) {
final marker = enhancedMessage.toSarMarker(); final marker = enhancedMessage.toSarMarker();
if (marker != null) { if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
_sarMarkers[marker.id] = marker; _sarMarkers[marker.id] = marker;
} }
} }
@@ -659,6 +813,80 @@ class MessagesProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
String? _normalizeSenderName(String? value) {
final trimmed = value?.trim();
if (trimmed == null || trimmed.isEmpty) {
return null;
}
var normalized = trimmed.toLowerCase();
if (normalized.startsWith('meshcore-')) {
normalized = normalized.substring('meshcore-'.length).trim();
}
return normalized.isEmpty ? null : normalized;
}
Message _resolveSenderNameIfNeeded(Message message) {
if (message.senderName != null || message.senderPublicKeyPrefix == null) {
return message;
}
final resolvedSenderName = resolveContactNameCallback?.call(
message.senderPublicKeyPrefix,
);
if (resolvedSenderName == null || resolvedSenderName.trim().isEmpty) {
return message;
}
return message.copyWith(senderName: resolvedSenderName.trim());
}
String? _normalizedResolvedSenderName(Message message) {
final directName = _normalizeSenderName(message.senderName);
if (directName != null) {
return directName;
}
if (message.senderPublicKeyPrefix == null) {
return null;
}
final resolved = resolveContactNameCallback?.call(
message.senderPublicKeyPrefix,
);
return _normalizeSenderName(resolved);
}
void _scheduleChannelEchoWarning(String messageId) {
_channelEchoWarningTimers[messageId]?.cancel();
_channelEchoWarningMessageIds.remove(messageId);
_channelEchoWarningTimers[messageId] = Timer(_channelEchoWarningDelay, () {
final index = _messages.indexWhere((m) => m.id == messageId);
_channelEchoWarningTimers.remove(messageId);
if (index == -1) {
return;
}
final message = _messages[index];
if (!message.isChannelMessage ||
!message.isSentMessage ||
message.deliveryStatus != MessageDeliveryStatus.sent ||
message.echoCount > 0) {
return;
}
_channelEchoWarningMessageIds.add(messageId);
notifyListeners();
});
}
void _clearChannelSendWarning(String messageId) {
_channelEchoWarningTimers[messageId]?.cancel();
_channelEchoWarningTimers.remove(messageId);
_channelEchoWarningMessageIds.remove(messageId);
}
/// Trigger urgent notification for SAR marker /// Trigger urgent notification for SAR marker
Future<void> _triggerSarNotification( Future<void> _triggerSarNotification(
Message message, Message message,
@@ -749,9 +977,17 @@ class MessagesProvider with ChangeNotifier {
final idx = channelIdx ?? 0; final idx = channelIdx ?? 0;
final resolved = resolveChannelNameCallback?.call(idx).trim(); final resolved = resolveChannelNameCallback?.call(idx).trim();
if (resolved != null && resolved.isNotEmpty) { if (resolved != null && resolved.isNotEmpty) {
if (idx == 0 && resolved == 'Public') {
return _localizations?.publicChannel ?? resolved;
}
return resolved; return resolved;
} }
return idx == 0 ? 'Public' : 'Channel $idx'; if (idx == 0) {
return _localizations?.publicChannel ?? 'Public';
}
return _localizations != null
? '${_localizations!.channel} $idx'
: 'Channel $idx';
} }
String _buildNotificationMessageText( String _buildNotificationMessageText(
@@ -796,9 +1032,19 @@ class MessagesProvider with ChangeNotifier {
return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB'; return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB';
} }
/// Persist messages to storage (async, non-blocking) /// Persist messages to storage (async, non-blocking, coalescing).
///
/// Multiple rapid calls are coalesced into a single write to avoid
/// overlapping serialization and redundant SharedPreferences writes.
Future<void> _persistMessages() async { Future<void> _persistMessages() async {
_persistRequested = true;
if (_isPersisting) {
return; // A write is in flight; it will pick up our changes.
}
_isPersisting = true;
try { try {
while (_persistRequested) {
_persistRequested = false;
await _storageService.saveMessages( await _storageService.saveMessages(
_messages, _messages,
messageContactLocations: _messageContactLocations, messageContactLocations: _messageContactLocations,
@@ -806,8 +1052,11 @@ class MessagesProvider with ChangeNotifier {
messageTransferDetails: _messageTransferDetails, messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata, messageRouteMetadata: _messageRouteMetadata,
); );
}
} catch (e) { } catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e'); debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
} finally {
_isPersisting = false;
} }
} }
@@ -830,6 +1079,55 @@ class MessagesProvider with ChangeNotifier {
.toList(); .toList();
} }
/// Remove all messages associated with a specific channel.
void clearChannelMessages(int channelIdx) {
final messageIdsToRemove = _messages
.where(
(message) =>
message.isChannelMessage && message.channelIdx == channelIdx,
)
.map((message) => message.id)
.toList();
if (messageIdsToRemove.isEmpty) {
return;
}
_messages.removeWhere(
(message) => message.isChannelMessage && message.channelIdx == channelIdx,
);
for (final messageId in messageIdsToRemove) {
_timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId);
_clearChannelSendWarning(messageId);
_messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId);
_messageReceptionDetails.remove(messageId);
_messageTransferDetails.remove(messageId);
_messageRouteMetadata.remove(messageId);
}
_pendingSentMessages.removeWhere(
(_, message) =>
message.isChannelMessage && message.channelIdx == channelIdx,
);
_messageAckHistory.removeWhere(
(messageId, _) => messageIdsToRemove.contains(messageId),
);
_ackHistoryLookup.removeWhere(
(_, value) => messageIdsToRemove.contains(value.$1),
);
debugPrint(
'🗑️ [MessagesProvider] Cleared ${messageIdsToRemove.length} message(s) for channel $channelIdx',
);
_persistMessages();
notifyListeners();
}
/// Get recent messages (last N messages) /// Get recent messages (last N messages)
List<Message> getRecentMessages({int count = 50}) { List<Message> getRecentMessages({int count = 50}) {
final sorted = List<Message>.from(_messages) final sorted = List<Message>.from(_messages)
@@ -837,6 +1135,35 @@ class MessagesProvider with ChangeNotifier {
return sorted.take(count).toList(); return sorted.take(count).toList();
} }
List<DisplayMessageEntry> buildDisplayMessages(Iterable<Message> messages) {
final entries = <DisplayMessageEntry>[];
for (final message in messages) {
final occurrenceCount = _messageOccurrenceCount(message);
final existingIndex = entries.indexWhere(
(entry) =>
entry.message.text == message.text &&
_matchesDuplicateScope(entry.message, message),
);
if (existingIndex == -1) {
entries.add((message: message, occurrenceCount: occurrenceCount));
continue;
}
final existingEntry = entries[existingIndex];
entries[existingIndex] = (
message: existingEntry.message,
occurrenceCount: existingEntry.occurrenceCount + occurrenceCount,
);
}
return entries;
}
int _messageOccurrenceCount(Message message) =>
_messageReceptionDetails[message.id]?.receivedCopies ?? 1;
/// Get messages from last N hours /// Get messages from last N hours
List<Message> getMessagesSince(Duration duration) { List<Message> getMessagesSince(Duration duration) {
final cutoff = DateTime.now().subtract(duration); final cutoff = DateTime.now().subtract(duration);
@@ -857,17 +1184,35 @@ class MessagesProvider with ChangeNotifier {
return _sarMarkers[id]; return _sarMarkers[id];
} }
Message? getMessageById(String id) {
final index = _messages.indexWhere((message) => message.id == id);
if (index == -1) return null;
return _messages[index];
}
/// Get recent SAR markers (within last hour) /// Get recent SAR markers (within last hour)
List<SarMarker> getRecentSarMarkers() { List<SarMarker> getRecentSarMarkers() {
return sarMarkers.where((m) => m.isRecent).toList(); return sarMarkers.where((m) => m.isRecent).toList();
} }
/// Remove a SAR marker /// Remove a SAR marker
void removeSarMarker(String id) { Future<void> removeSarMarker(String id) async {
_sarMarkers.remove(id); _sarMarkers.remove(id);
_removedSarMarkerIds.add(id);
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
notifyListeners(); notifyListeners();
} }
Future<void> removeSarMarkerPermanently(String id) async {
final hasBackingMessage = _messages.any((message) => message.id == id);
if (hasBackingMessage) {
deleteMessage(id);
return;
}
await removeSarMarker(id);
}
/// Mark all messages as read /// Mark all messages as read
void markAllAsRead() { void markAllAsRead() {
bool hasChanges = false; bool hasChanges = false;
@@ -904,7 +1249,8 @@ class MessagesProvider with ChangeNotifier {
final matchesDestination = switch (destinationType) { final matchesDestination = switch (destinationType) {
'channel' => _isChannelMessageForContact(message, contact), 'channel' => _isChannelMessageForContact(message, contact),
'contact' || 'room' => contact != null && _isMessageForDestination(message, contact), 'contact' ||
'room' => contact != null && _isMessageForDestination(message, contact),
_ => false, _ => false,
}; };
@@ -954,6 +1300,7 @@ class MessagesProvider with ChangeNotifier {
final marker = message.toSarMarker(); final marker = message.toSarMarker();
if (marker != null) { if (marker != null) {
_sarMarkers.remove(marker.id); _sarMarkers.remove(marker.id);
_removedSarMarkerIds.add(marker.id);
} }
} }
@@ -963,6 +1310,7 @@ class MessagesProvider with ChangeNotifier {
// Cancel timeout timer if it exists // Cancel timeout timer if it exists
_timeoutTimers[message.id]?.cancel(); _timeoutTimers[message.id]?.cancel();
_timeoutTimers.remove(message.id); _timeoutTimers.remove(message.id);
_clearChannelSendWarning(message.id);
if (message.expectedAckTag != null) { if (message.expectedAckTag != null) {
_pendingSentMessages.remove(message.expectedAckTag); _pendingSentMessages.remove(message.expectedAckTag);
} }
@@ -976,6 +1324,7 @@ class MessagesProvider with ChangeNotifier {
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted'); debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
_persistMessages(); _persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners(); notifyListeners();
} }
} }
@@ -998,31 +1347,55 @@ class MessagesProvider with ChangeNotifier {
/// Clear all messages /// Clear all messages
void clearMessages() { void clearMessages() {
_cancelAllTimers();
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_removedSarMarkerIds.clear();
_messageContactLocations.clear(); _messageContactLocations.clear();
_messageReceptionDetails.clear(); _messageReceptionDetails.clear();
_messageTransferDetails.clear(); _messageTransferDetails.clear();
_messageRouteMetadata.clear(); _messageRouteMetadata.clear();
_pendingSentMessages.clear();
_messageContactMap.clear();
_groupedMessageMapping.clear();
_ackTagToRecipients.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
_completedAckHistory.clear();
_retryManager.clearAll();
_persistMessages(); _persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners(); notifyListeners();
} }
/// Clear all SAR markers /// Clear all SAR markers
void clearSarMarkers() { Future<void> clearSarMarkers() async {
_removedSarMarkerIds.addAll(_sarMarkers.keys);
_sarMarkers.clear(); _sarMarkers.clear();
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
notifyListeners(); notifyListeners();
} }
/// Clear all data /// Clear all data
void clearAll() { void clearAll() {
_cancelAllTimers();
_messages.clear(); _messages.clear();
_sarMarkers.clear(); _sarMarkers.clear();
_removedSarMarkerIds.clear();
_messageContactLocations.clear(); _messageContactLocations.clear();
_messageReceptionDetails.clear(); _messageReceptionDetails.clear();
_messageTransferDetails.clear(); _messageTransferDetails.clear();
_messageRouteMetadata.clear(); _messageRouteMetadata.clear();
_pendingSentMessages.clear();
_messageContactMap.clear();
_groupedMessageMapping.clear();
_ackTagToRecipients.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
_completedAckHistory.clear();
_retryManager.clearAll();
_persistMessages(); _persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners(); notifyListeners();
} }
@@ -1181,9 +1554,10 @@ class MessagesProvider with ChangeNotifier {
); );
} }
} }
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
// Check for duplicates (shouldn't happen for sent messages, but be safe) // Check for duplicates (shouldn't happen for sent messages, but be safe)
if (_isDuplicate(enhancedMessage)) { if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
debugPrint( debugPrint(
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}', '⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
); );
@@ -1208,7 +1582,7 @@ class MessagesProvider with ChangeNotifier {
// If it's a SAR marker message, extract and store the marker // If it's a SAR marker message, extract and store the marker
if (sendingMessage.isSarMarker) { if (sendingMessage.isSarMarker) {
final marker = sendingMessage.toSarMarker(); final marker = sendingMessage.toSarMarker();
if (marker != null) { if (marker != null && !_removedSarMarkerIds.contains(marker.id)) {
debugPrint(' ✅ SAR Marker created:'); debugPrint(' ✅ SAR Marker created:');
debugPrint(' marker.id: ${marker.id}'); debugPrint(' marker.id: ${marker.id}');
debugPrint(' marker.notes: "${marker.notes}"'); debugPrint(' marker.notes: "${marker.notes}"');
@@ -1427,6 +1801,9 @@ class MessagesProvider with ChangeNotifier {
debugPrint( debugPrint(
' Channel message (no ACK tracking) - marked as sent immediately', ' Channel message (no ACK tracking) - marked as sent immediately',
); );
if (message.isChannelMessage) {
_scheduleChannelEchoWarning(messageId);
}
} }
debugPrint(' Calling notifyListeners() to update UI with "sent" status'); debugPrint(' Calling notifyListeners() to update UI with "sent" status');
@@ -1475,6 +1852,7 @@ class MessagesProvider with ChangeNotifier {
lastEchoAt: DateTime.now(), lastEchoAt: DateTime.now(),
); );
_messages[index] = updatedMessage; _messages[index] = updatedMessage;
_clearChannelSendWarning(messageId);
debugPrint(' Updated echo count to: $echoCount'); debugPrint(' Updated echo count to: $echoCount');
_persistMessages(); _persistMessages();
@@ -1696,7 +2074,7 @@ class MessagesProvider with ChangeNotifier {
debugPrint(' Message index in list: $index'); debugPrint(' Message index in list: $index');
if (index != -1) { if (index != -1) {
final updatedMessage = message.copyWith( final updatedMessage = _messages[index].copyWith(
deliveryStatus: MessageDeliveryStatus.delivered, deliveryStatus: MessageDeliveryStatus.delivered,
roundTripTimeMs: roundTripTimeMs, roundTripTimeMs: roundTripTimeMs,
deliveredAt: DateTime.now(), deliveredAt: DateTime.now(),
@@ -1850,6 +2228,7 @@ class MessagesProvider with ChangeNotifier {
} }
final message = _messages[index]; final message = _messages[index];
_clearChannelSendWarning(messageId);
final contact = _messageContactMap[messageId]; final contact = _messageContactMap[messageId];
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed'); debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
@@ -1889,6 +2268,7 @@ class MessagesProvider with ChangeNotifier {
deliveryStatus: MessageDeliveryStatus.sending, deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(), lastRetryAt: DateTime.now(),
); );
_clearChannelSendWarning(messageId);
// Cancel old timeout timer // Cancel old timeout timer
_timeoutTimers[message.id]?.cancel(); _timeoutTimers[message.id]?.cancel();
@@ -1954,6 +2334,7 @@ class MessagesProvider with ChangeNotifier {
deliveryStatus: MessageDeliveryStatus.sending, deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(), lastRetryAt: DateTime.now(),
); );
_clearChannelSendWarning(messageId);
_timeoutTimers[message.id]?.cancel(); _timeoutTimers[message.id]?.cancel();
_timeoutTimers.remove(message.id); _timeoutTimers.remove(message.id);
@@ -2044,6 +2425,7 @@ class MessagesProvider with ChangeNotifier {
_clearAckHistoryForMessage(messageId); _clearAckHistoryForMessage(messageId);
_retryManager.clearRetry(messageId); _retryManager.clearRetry(messageId);
_messageRouteMetadata.remove(messageId); _messageRouteMetadata.remove(messageId);
onManualRetryPreparedCallback?.call(messageId);
_messages[index] = Message( _messages[index] = Message(
id: message.id, id: message.id,
@@ -2051,6 +2433,7 @@ class MessagesProvider with ChangeNotifier {
senderPublicKeyPrefix: message.senderPublicKeyPrefix, senderPublicKeyPrefix: message.senderPublicKeyPrefix,
channelIdx: message.channelIdx, channelIdx: message.channelIdx,
pathLen: message.pathLen, pathLen: message.pathLen,
pathBytes: message.pathBytes,
textType: message.textType, textType: message.textType,
senderTimestamp: message.senderTimestamp, senderTimestamp: message.senderTimestamp,
text: message.text, text: message.text,
@@ -2079,6 +2462,7 @@ class MessagesProvider with ChangeNotifier {
isVoice: message.isVoice, isVoice: message.isVoice,
voiceId: message.voiceId, voiceId: message.voiceId,
); );
_clearChannelSendWarning(messageId);
_persistMessages(); _persistMessages();
notifyListeners(); notifyListeners();
@@ -2137,11 +2521,7 @@ class MessagesProvider with ChangeNotifier {
@override @override
void dispose() { void dispose() {
// Cancel all pending timeout timers _cancelAllTimers();
for (final timer in _timeoutTimers.values) {
timer.cancel();
}
_timeoutTimers.clear();
_completedAckHistory.clear(); _completedAckHistory.clear();
_messageAckHistory.clear(); _messageAckHistory.clear();
_ackHistoryLookup.clear(); _ackHistoryLookup.clear();
@@ -2152,6 +2532,19 @@ class MessagesProvider with ChangeNotifier {
super.dispose(); super.dispose();
} }
/// Cancel all pending timers (timeout + echo warning).
void _cancelAllTimers() {
for (final timer in _timeoutTimers.values) {
timer.cancel();
}
_timeoutTimers.clear();
for (final timer in _channelEchoWarningTimers.values) {
timer.cancel();
}
_channelEchoWarningTimers.clear();
_channelEchoWarningMessageIds.clear();
}
void _rememberCompletedAck(int ackCode) { void _rememberCompletedAck(int ackCode) {
_completedAckHistory[ackCode] = DateTime.now(); _completedAckHistory[ackCode] = DateTime.now();
_cleanupCompletedAckHistory(); _cleanupCompletedAckHistory();

View File

@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../widgets/sensors/sensor_telemetry_card.dart';
import 'connection_provider.dart'; import 'connection_provider.dart';
import 'contacts_provider.dart'; import 'contacts_provider.dart';
@@ -15,6 +16,19 @@ class SensorsProvider with ChangeNotifier {
static const String _watchedSensorsKey = 'watched_sensor_keys'; static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics'; static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
static const String _fieldSpanKey = 'sensor_field_spans'; static const String _fieldSpanKey = 'sensor_field_spans';
static const String _metricLabelKey = 'sensor_metric_labels';
static const String _metricOrderKey = 'sensor_metric_order';
static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes';
static const List<int> supportedAutoRefreshIntervals = <int>[
0,
5,
15,
30,
60,
360,
720,
1440,
];
static const Set<String> _defaultVisibleFields = <String>{ static const Set<String> _defaultVisibleFields = <String>{
'voltage', 'voltage',
'battery', 'battery',
@@ -23,6 +37,14 @@ class SensorsProvider with ChangeNotifier {
'pressure', 'pressure',
'gps', 'gps',
}; };
static const List<String> _defaultMetricOrder = <String>[
'voltage',
'battery',
'temperature',
'humidity',
'pressure',
'gps',
];
final List<String> _watchedSensorKeys = <String>[]; final List<String> _watchedSensorKeys = <String>[];
final Map<String, SensorRefreshState> _refreshStates = final Map<String, SensorRefreshState> _refreshStates =
@@ -32,8 +54,15 @@ class SensorsProvider with ChangeNotifier {
<String, Set<String>>{}; <String, Set<String>>{};
final Map<String, Map<String, int>> _fieldSpansBySensor = final Map<String, Map<String, int>> _fieldSpansBySensor =
<String, Map<String, int>>{}; <String, Map<String, int>>{};
final Map<String, Map<String, String>> _metricLabelsBySensor =
<String, Map<String, String>>{};
final Map<String, List<String>> _metricOrderBySensor =
<String, List<String>>{};
final Map<String, int> _autoRefreshMinutesBySensor = <String, int>{};
final Map<String, DateTime> _lastRefreshAttemptAt = <String, DateTime>{};
bool _isLoaded = false; bool _isLoaded = false;
bool _isRefreshingAll = false; bool _isRefreshingAll = false;
bool _isRunningAutoRefreshTick = false;
SensorsProvider() { SensorsProvider() {
unawaited(_loadWatchedSensors()); unawaited(_loadWatchedSensors());
@@ -52,11 +81,17 @@ class SensorsProvider with ChangeNotifier {
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[]; final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey); final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
final storedSpansJson = prefs.getString(_fieldSpanKey); final storedSpansJson = prefs.getString(_fieldSpanKey);
final storedLabelsJson = prefs.getString(_metricLabelKey);
final storedOrderJson = prefs.getString(_metricOrderKey);
final storedAutoRefreshJson = prefs.getString(_autoRefreshMinutesKey);
_watchedSensorKeys _watchedSensorKeys
..clear() ..clear()
..addAll(stored); ..addAll(stored);
_visibleFieldsBySensor.clear(); _visibleFieldsBySensor.clear();
_fieldSpansBySensor.clear(); _fieldSpansBySensor.clear();
_metricLabelsBySensor.clear();
_metricOrderBySensor.clear();
_autoRefreshMinutesBySensor.clear();
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) { if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>; final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
for (final entry in decoded.entries) { for (final entry in decoded.entries) {
@@ -72,12 +107,49 @@ class SensorsProvider with ChangeNotifier {
.map((key, value) => MapEntry(key, value as int)); .map((key, value) => MapEntry(key, value as int));
} }
} }
if (storedLabelsJson != null && storedLabelsJson.isNotEmpty) {
final decoded = jsonDecode(storedLabelsJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
_metricLabelsBySensor[entry.key] =
(entry.value as Map<String, dynamic>).map(
(key, value) => MapEntry(key, value as String),
);
}
}
if (storedOrderJson != null && storedOrderJson.isNotEmpty) {
final decoded = jsonDecode(storedOrderJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
_metricOrderBySensor[entry.key] = (entry.value as List<dynamic>)
.cast<String>()
.toList();
}
}
if (storedAutoRefreshJson != null && storedAutoRefreshJson.isNotEmpty) {
final decoded =
jsonDecode(storedAutoRefreshJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
final minutes = _normalizeAutoRefreshMinutes(
(entry.value as num).toInt(),
);
if (minutes > 0) {
_autoRefreshMinutesBySensor[entry.key] = minutes;
}
}
}
_autoRefreshMinutesBySensor.removeWhere(
(key, _) => !_watchedSensorKeys.contains(key),
);
for (final key in _watchedSensorKeys) { for (final key in _watchedSensorKeys) {
_visibleFieldsBySensor.putIfAbsent( _visibleFieldsBySensor.putIfAbsent(
key, key,
() => Set<String>.from(_defaultVisibleFields), () => Set<String>.from(_defaultVisibleFields),
); );
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{}); _fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
_metricLabelsBySensor.putIfAbsent(key, () => <String, String>{});
_metricOrderBySensor.putIfAbsent(
key,
() => List<String>.from(_defaultMetricOrder),
);
} }
} catch (e) { } catch (e) {
debugPrint('Error loading watched sensors: $e'); debugPrint('Error loading watched sensors: $e');
@@ -118,10 +190,56 @@ class SensorsProvider with ChangeNotifier {
} }
} }
Future<void> _persistMetricLabels() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metricLabelKey, jsonEncode(_metricLabelsBySensor));
} catch (e) {
debugPrint('Error saving sensor metric labels: $e');
}
}
Future<void> _persistMetricOrder() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metricOrderKey, jsonEncode(_metricOrderBySensor));
} catch (e) {
debugPrint('Error saving sensor metric order: $e');
}
}
Future<void> _persistAutoRefreshMinutes() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_autoRefreshMinutesKey,
jsonEncode(_autoRefreshMinutesBySensor),
);
} catch (e) {
debugPrint('Error saving sensor auto refresh minutes: $e');
}
}
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable( Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields, _visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
); );
Set<String> effectiveVisibleFieldsFor(
String publicKeyHex,
Iterable<String> availableFieldKeys,
) {
final storedVisibleFields =
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields;
if (!_shouldAutoIncludeAvailableFields(publicKeyHex, storedVisibleFields)) {
return Set<String>.unmodifiable(storedVisibleFields);
}
return Set<String>.unmodifiable(<String>{
...storedVisibleFields,
...availableFieldKeys,
});
}
bool showsField(String publicKeyHex, String fieldKey) => bool showsField(String publicKeyHex, String fieldKey) =>
visibleFieldsFor(publicKeyHex).contains(fieldKey); visibleFieldsFor(publicKeyHex).contains(fieldKey);
@@ -131,6 +249,104 @@ class SensorsProvider with ChangeNotifier {
return span == 2 ? 2 : 1; return span == 2 ? 2 : 1;
} }
List<String> metricOrderFor(
String publicKeyHex,
Iterable<String> availableFieldKeys,
) {
final available = availableFieldKeys.toList();
final availableSet = available.toSet();
final ordered = <String>[];
final seen = <String>{};
final stored =
_metricOrderBySensor[publicKeyHex] ??
List<String>.from(_defaultMetricOrder);
for (final fieldKey in stored) {
if (availableSet.contains(fieldKey) && seen.add(fieldKey)) {
ordered.add(fieldKey);
}
}
for (final fieldKey in available) {
if (seen.add(fieldKey)) {
ordered.add(fieldKey);
}
}
return List<String>.unmodifiable(ordered);
}
Map<String, String> labelOverridesFor(String publicKeyHex) =>
Map<String, String>.unmodifiable(
_metricLabelsBySensor[publicKeyHex] ?? const <String, String>{},
);
String? labelOverrideFor(String publicKeyHex, String fieldKey) =>
_metricLabelsBySensor[publicKeyHex]?[fieldKey];
int autoRefreshMinutesFor(String publicKeyHex) =>
_autoRefreshMinutesBySensor[publicKeyHex] ?? 0;
Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async {
final normalizedMinutes = _normalizeAutoRefreshMinutes(minutes);
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
if (currentMinutes == normalizedMinutes) {
return;
}
if (normalizedMinutes == 0) {
_autoRefreshMinutesBySensor.remove(publicKeyHex);
} else {
_autoRefreshMinutesBySensor[publicKeyHex] = normalizedMinutes;
}
await _persistAutoRefreshMinutes();
notifyListeners();
}
static int _normalizeAutoRefreshMinutes(int minutes) {
if (minutes <= 0) {
return 0;
}
if (supportedAutoRefreshIntervals.contains(minutes)) {
return minutes;
}
final positiveIntervals = supportedAutoRefreshIntervals.where(
(value) => value > 0,
);
var bestMatch = positiveIntervals.first;
var bestDistance = (minutes - bestMatch).abs();
for (final interval in positiveIntervals.skip(1)) {
final distance = (minutes - interval).abs();
if (distance < bestDistance) {
bestMatch = interval;
bestDistance = distance;
}
}
return bestMatch;
}
List<String> dueAutoRefreshSensorKeys({DateTime? now}) {
final refreshTime = now ?? DateTime.now();
final dueKeys = <String>[];
for (final key in _watchedSensorKeys) {
final minutes = autoRefreshMinutesFor(key);
if (minutes <= 0) {
continue;
}
final lastRefreshAt = _lastRefreshAttemptAt[key];
if (lastRefreshAt == null ||
refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes)) {
dueKeys.add(key);
}
}
return List<String>.unmodifiable(dueKeys);
}
Future<void> toggleMetric( Future<void> toggleMetric(
String publicKeyHex, String publicKeyHex,
String fieldKey, String fieldKey,
@@ -140,8 +356,17 @@ class SensorsProvider with ChangeNotifier {
publicKeyHex, publicKeyHex,
() => Set<String>.from(_defaultVisibleFields), () => Set<String>.from(_defaultVisibleFields),
); );
final metricOrder = _metricOrderBySensor.putIfAbsent(
publicKeyHex,
() => List<String>.from(_defaultMetricOrder),
);
var shouldPersistOrder = false;
if (visible) { if (visible) {
visibleFields.add(fieldKey); visibleFields.add(fieldKey);
if (!metricOrder.contains(fieldKey)) {
metricOrder.add(fieldKey);
shouldPersistOrder = true;
}
} else { } else {
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) { if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
return; return;
@@ -149,6 +374,9 @@ class SensorsProvider with ChangeNotifier {
visibleFields.remove(fieldKey); visibleFields.remove(fieldKey);
} }
await _persistVisibleMetrics(); await _persistVisibleMetrics();
if (shouldPersistOrder) {
await _persistMetricOrder();
}
notifyListeners(); notifyListeners();
} }
@@ -166,11 +394,62 @@ class SensorsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> setMetricLabel(
String publicKeyHex,
String fieldKey,
String? label,
) async {
final sensorLabels = _metricLabelsBySensor.putIfAbsent(
publicKeyHex,
() => <String, String>{},
);
final trimmed = label?.trim();
if (trimmed == null || trimmed.isEmpty) {
sensorLabels.remove(fieldKey);
} else {
sensorLabels[fieldKey] = trimmed;
}
if (sensorLabels.isEmpty) {
_metricLabelsBySensor.remove(publicKeyHex);
}
await _persistMetricLabels();
notifyListeners();
}
Future<void> moveMetric(
String publicKeyHex, {
required List<String> availableFieldKeys,
required int oldIndex,
required int newIndex,
}) async {
if (oldIndex < 0 ||
newIndex < 0 ||
oldIndex >= availableFieldKeys.length ||
newIndex >= availableFieldKeys.length ||
oldIndex == newIndex) {
return;
}
final reordered = List<String>.from(
metricOrderFor(publicKeyHex, availableFieldKeys),
);
final fieldKey = reordered.removeAt(oldIndex);
reordered.insert(newIndex, fieldKey);
final storedTail =
(_metricOrderBySensor[publicKeyHex] ?? _defaultMetricOrder).where(
(key) => !reordered.contains(key),
);
_metricOrderBySensor[publicKeyHex] = <String>[...reordered, ...storedTail];
await _persistMetricOrder();
notifyListeners();
}
bool isWatched(String publicKeyHex) => bool isWatched(String publicKeyHex) =>
_watchedSensorKeys.contains(publicKeyHex); _watchedSensorKeys.contains(publicKeyHex);
Future<void> addSensor(Contact contact) async { Future<void> addSensor(Contact contact) async {
if (!contact.isChat && !contact.isRepeater) { if (!contact.isChat && !contact.isRepeater && !contact.isSensor) {
return; return;
} }
if (_watchedSensorKeys.contains(contact.publicKeyHex)) { if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
@@ -178,25 +457,69 @@ class SensorsProvider with ChangeNotifier {
} }
_watchedSensorKeys.add(contact.publicKeyHex); _watchedSensorKeys.add(contact.publicKeyHex);
final initialVisibleFields = _initialVisibleFieldsForContact(contact);
await _persistWatchedSensors(); await _persistWatchedSensors();
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from( _visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields;
_defaultVisibleFields,
);
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2}; _fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
_metricOrderBySensor[contact.publicKeyHex] = metricOrderFor(
contact.publicKeyHex,
initialVisibleFields,
);
await _persistVisibleMetrics(); await _persistVisibleMetrics();
await _persistFieldSpans(); await _persistFieldSpans();
await _persistMetricLabels();
await _persistMetricOrder();
notifyListeners(); notifyListeners();
} }
bool _shouldAutoIncludeAvailableFields(
String publicKeyHex,
Set<String> storedVisibleFields,
) {
if (!setEquals(storedVisibleFields, _defaultVisibleFields)) {
return false;
}
final storedOrder = _metricOrderBySensor[publicKeyHex];
if (storedOrder != null && !listEquals(storedOrder, _defaultMetricOrder)) {
return false;
}
final storedLabels = _metricLabelsBySensor[publicKeyHex];
if (storedLabels != null && storedLabels.isNotEmpty) {
return false;
}
final storedSpans = _fieldSpansBySensor[publicKeyHex];
if (storedSpans != null &&
!(storedSpans.length == 1 && storedSpans['gps'] == 2)) {
return false;
}
return true;
}
Set<String> _initialVisibleFieldsForContact(Contact contact) {
return <String>{..._defaultVisibleFields, ...sensorMetricKeysFor(contact)};
}
Future<void> removeSensor(String publicKeyHex) async { Future<void> removeSensor(String publicKeyHex) async {
_watchedSensorKeys.remove(publicKeyHex); _watchedSensorKeys.remove(publicKeyHex);
_refreshStates.remove(publicKeyHex); _refreshStates.remove(publicKeyHex);
_refreshStateUpdatedAt.remove(publicKeyHex); _refreshStateUpdatedAt.remove(publicKeyHex);
_visibleFieldsBySensor.remove(publicKeyHex); _visibleFieldsBySensor.remove(publicKeyHex);
_fieldSpansBySensor.remove(publicKeyHex); _fieldSpansBySensor.remove(publicKeyHex);
_metricLabelsBySensor.remove(publicKeyHex);
_metricOrderBySensor.remove(publicKeyHex);
_autoRefreshMinutesBySensor.remove(publicKeyHex);
_lastRefreshAttemptAt.remove(publicKeyHex);
await _persistWatchedSensors(); await _persistWatchedSensors();
await _persistVisibleMetrics(); await _persistVisibleMetrics();
await _persistFieldSpans(); await _persistFieldSpans();
await _persistMetricLabels();
await _persistMetricOrder();
await _persistAutoRefreshMinutes();
notifyListeners(); notifyListeners();
} }
@@ -204,6 +527,7 @@ class SensorsProvider with ChangeNotifier {
final candidates = <Contact>[ final candidates = <Contact>[
...contactsProvider.chatContacts, ...contactsProvider.chatContacts,
...contactsProvider.repeaters, ...contactsProvider.repeaters,
...contactsProvider.sensorContacts,
]; ];
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex)); candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime)); candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
@@ -239,7 +563,14 @@ class SensorsProvider with ChangeNotifier {
required String publicKeyHex, required String publicKeyHex,
required ContactsProvider contactsProvider, required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider, required ConnectionProvider connectionProvider,
DateTime? requestedAt,
}) async { }) async {
if (stateFor(publicKeyHex) == SensorRefreshState.refreshing) {
return;
}
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
Contact? contact; Contact? contact;
for (final entry in contactsProvider.contacts) { for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) { if (entry.publicKeyHex == publicKeyHex) {
@@ -266,6 +597,38 @@ class SensorsProvider with ChangeNotifier {
); );
} }
Future<void> refreshDueSensors({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
DateTime? now,
}) async {
if (!connectionProvider.deviceInfo.isConnected ||
_isRefreshingAll ||
_isRunningAutoRefreshTick) {
return;
}
final refreshTime = now ?? DateTime.now();
final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime);
if (dueKeys.isEmpty) {
return;
}
_isRunningAutoRefreshTick = true;
try {
for (final key in dueKeys) {
await refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
requestedAt: refreshTime,
);
}
} finally {
_isRunningAutoRefreshTick = false;
}
}
void clearExpiredRefreshStates({DateTime? now}) { void clearExpiredRefreshStates({DateTime? now}) {
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention); final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
final keysToClear = <String>[]; final keysToClear = <String>[];

View File

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

View File

@@ -0,0 +1,386 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../providers/connection_provider.dart';
class AddContactScreen extends StatefulWidget {
const AddContactScreen({super.key});
@override
State<AddContactScreen> createState() => _AddContactScreenState();
}
class _AddContactScreenState extends State<AddContactScreen> {
final TextEditingController _advertController = TextEditingController();
bool _isImporting = false;
bool _importSucceeded = false;
String? _validationError;
@override
void initState() {
super.initState();
_loadClipboardIfPresent();
}
@override
void dispose() {
_advertController.dispose();
super.dispose();
}
Future<void> _loadClipboardIfPresent() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text?.trim();
if (!mounted || text == null || text.isEmpty) {
return;
}
if (_normalizeAdvertText(text) == null) {
return;
}
_advertController.text = text;
}
String? _normalizeAdvertText(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) {
return null;
}
var normalized = trimmed;
if (normalized.startsWith('meshcore://')) {
normalized = normalized.substring('meshcore://'.length);
}
normalized = normalized.replaceAll(RegExp(r'\s+'), '');
if (normalized.isEmpty) {
return null;
}
final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(normalized);
if (!isHex || normalized.length.isOdd) {
return null;
}
return normalized.toLowerCase();
}
Uint8List _hexToBytes(String hex) {
final bytes = <int>[];
for (var i = 0; i < hex.length; i += 2) {
bytes.add(int.parse(hex.substring(i, i + 2), radix: 16));
}
return Uint8List.fromList(bytes);
}
Future<void> _pasteFromClipboard() async {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text;
if (text == null || text.trim().isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Clipboard is empty')));
return;
}
setState(() {
_advertController.text = text.trim();
_importSucceeded = false;
_validationError = null;
});
}
Future<void> _importContact() async {
final normalized = _normalizeAdvertText(_advertController.text);
if (normalized == null) {
setState(() {
_validationError =
'Enter a valid meshcore:// advert or raw hexadecimal contact advert.';
});
return;
}
final advertBytes = _hexToBytes(normalized);
if (advertBytes.length < 98) {
setState(() {
_validationError =
'Advert is too short. Expected exported contact data.';
});
return;
}
setState(() {
_isImporting = true;
_importSucceeded = false;
_validationError = null;
});
final connectionProvider = context.read<ConnectionProvider>();
await connectionProvider.importContactAdvert(advertBytes);
final importError = connectionProvider.error;
if (importError == null) {
await connectionProvider.getContacts();
}
if (!mounted) {
return;
}
setState(() {
_isImporting = false;
});
if (importError != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(importError)));
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Contact imported')));
setState(() {
_importSucceeded = true;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final normalized = _normalizeAdvertText(_advertController.text);
final colorScheme = theme.colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('Import Contact')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.person_add_alt_1_outlined,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Import a shared contact advert',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 14),
Text(
'Paste a `meshcore://...` link or raw hexadecimal advert. The app will validate it and import the contact into the connected device.',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
_ImportHintChip(
icon: Icons.link_outlined,
label: 'Accepts share links',
),
_ImportHintChip(
icon: Icons.code_outlined,
label: 'Supports raw hex',
),
_ImportHintChip(
icon: Icons.content_paste_go_outlined,
label: 'Clipboard-friendly',
),
],
),
],
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.7),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Advert payload',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'You can paste the full share link or only the hex payload.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
TextField(
controller: _advertController,
minLines: 5,
maxLines: 9,
onChanged: (_) {
if (_validationError != null || _importSucceeded) {
setState(() {
_importSucceeded = false;
_validationError = null;
});
}
},
decoration: InputDecoration(
labelText: 'Contact advert',
hintText: 'Paste a share link or hex advert',
alignLabelWithHint: true,
border: const OutlineInputBorder(),
errorText: _validationError,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Text(
normalized == null
? 'No valid advert detected yet'
: 'Advert size: ${normalized.length ~/ 2} bytes',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
onPressed: _isImporting ? null : _pasteFromClipboard,
icon: const Icon(Icons.content_paste_go_outlined),
label: const Text('Paste'),
),
],
),
],
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: (_isImporting || _importSucceeded)
? null
: _importContact,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
icon: _isImporting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: _importSucceeded
? const Icon(Icons.check_circle_outline)
: const Icon(Icons.person_add_alt_1_outlined),
label: Text(
_isImporting
? 'Importing...'
: _importSucceeded
? 'Imported'
: 'Import Contact',
),
),
const SizedBox(height: 12),
if (_importSucceeded)
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.primary.withValues(alpha: 0.18),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.check_circle,
size: 18,
color: colorScheme.primary,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Contact imported. Paste another advert or edit the current one to import again.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
),
),
],
),
);
}
}
class _ImportHintChip extends StatelessWidget {
final IconData icon;
final String label;
const _ImportHintChip({required this.icon, required this.label});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
Text(label, style: theme.textTheme.labelMedium),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -12,11 +12,14 @@ import '../providers/contacts_provider.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import 'messages_tab.dart'; import 'messages_tab.dart';
import 'contacts_tab.dart'; import 'contacts_tab.dart';
import 'discovery_screen.dart';
import 'sensors_tab.dart'; import 'sensors_tab.dart';
import 'map_tab.dart'; import 'map_tab.dart';
import 'repeaters_map_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'device_config_screen.dart'; import 'device_config_screen.dart';
import 'packet_log_screen.dart'; import 'packet_log_screen.dart';
import 'live_traffic_screen.dart';
import 'spectrum_scan_screen.dart'; import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart'; import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -217,6 +220,10 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
void _openLiveTraffic(ConnectionProvider provider) {
openLiveTrafficScreen(context, provider);
}
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state; _lifecycleState = state;
@@ -646,6 +653,99 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
items.add(
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.radar_outlined),
SizedBox(width: 8),
Text('Live Traffic'),
],
),
onTap: () {
final navigator = Navigator.of(context);
final provider = context.read<ConnectionProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (_) => LiveTrafficScreen.fromProvider(
provider,
openPacketLogs: () {
navigator.push(
MaterialPageRoute(
builder: (_) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
),
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: const Row(
children: [
Icon(Icons.router_outlined),
SizedBox(width: 8),
Text('Repeaters Map'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const RepeatersMapScreen(),
fullscreenDialog: true,
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_search),
const SizedBox(width: 8),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final pendingCount =
contactsProvider.pendingAdverts.length;
return Text(
pendingCount > 0
? 'Discovery ($pendingCount)'
: 'Discovery',
);
},
),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
);
});
},
),
);
items.add( items.add(
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
@@ -687,6 +787,9 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) { switch (tab) {
case _HomeTab.messages: case _HomeTab.messages:
return MessagesTab( return MessagesTab(
isActive:
_currentTab == _HomeTab.messages &&
_lifecycleState == AppLifecycleState.resumed,
onNavigateToMap: _isMapEnabled onNavigateToMap: _isMapEnabled
? () => _navigateToTab(_HomeTab.map) ? () => _navigateToTab(_HomeTab.map)
: null, : null,
@@ -699,7 +802,11 @@ class _HomeScreenState extends State<HomeScreen>
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages), onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
); );
case _HomeTab.sensors: case _HomeTab.sensors:
return const SensorsTab(); return SensorsTab(
isActive:
_currentTab == _HomeTab.sensors &&
_lifecycleState == AppLifecycleState.resumed,
);
case _HomeTab.map: case _HomeTab.map:
return MapTab( return MapTab(
onFullscreenChanged: (isFullscreen) { onFullscreenChanged: (isFullscreen) {
@@ -746,7 +853,7 @@ class _HomeScreenState extends State<HomeScreen>
Icons.message, Icons.message,
unreadCount, unreadCount,
), ),
text: AppLocalizations.of(context)!.messages, text: 'Chat',
); );
case _HomeTab.contacts: case _HomeTab.contacts:
return Tab( return Tab(
@@ -1030,6 +1137,7 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox(width: isTight ? 8 : 12), SizedBox(width: isTight ? 8 : 12),
if (_showRxTxIndicators) if (_showRxTxIndicators)
GestureDetector( GestureDetector(
onTap: () => _openLiveTraffic(provider),
onLongPress: () { onLongPress: () {
Navigator.push( Navigator.push(
context, context,

File diff suppressed because it is too large Load Diff

View File

@@ -22,11 +22,13 @@ import '../models/message.dart';
import '../services/background_location_service.dart'; import '../services/background_location_service.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/map_marker_service.dart'; import '../services/map_marker_service.dart';
import '../services/message_destination_preferences.dart';
import '../services/trail_color_service.dart'; import '../services/trail_color_service.dart';
import '../widgets/map_debug_info.dart'; import '../widgets/map_debug_info.dart';
import '../widgets/map/compass_widget.dart'; import '../widgets/map/compass_widget.dart';
import '../widgets/map/detailed_compass_dialog.dart'; import '../widgets/map/detailed_compass_dialog.dart';
import '../widgets/map/drawing_layer.dart'; import '../widgets/map/drawing_layer.dart';
import '../widgets/map/drawing_toolbar.dart';
import '../widgets/map/location_trail_layer.dart'; import '../widgets/map/location_trail_layer.dart';
import '../widgets/map/trail_controls.dart'; import '../widgets/map/trail_controls.dart';
import '../widgets/map/map_message_overlay.dart'; import '../widgets/map/map_message_overlay.dart';
@@ -815,6 +817,174 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
} }
} }
Future<void> _showSarMarkerActions(
SarMarker marker,
MessagesProvider messagesProvider,
ContactsProvider contactsProvider,
) async {
final message = messagesProvider.getMessageById(marker.id);
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
final theme = Theme.of(sheetContext);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(marker.emoji, style: const TextStyle(fontSize: 24)),
const SizedBox(width: 10),
Expanded(
child: Text(
marker.displayName,
style: theme.textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 12),
Text(
'${marker.location.latitude.toStringAsFixed(6)}, ${marker.location.longitude.toStringAsFixed(6)}',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 4),
Text(
marker.senderName != null
? '${marker.timeAgo}${marker.senderName}'
: marker.timeAgo,
style: theme.textTheme.bodySmall,
),
if (marker.notes != null && marker.notes!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(marker.notes!),
],
const SizedBox(height: 16),
if (message != null)
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.chat_bubble_outline),
title: const Text('Open message'),
subtitle: const Text('Jump to the related SAR message'),
onTap: () async {
Navigator.pop(sheetContext);
await _openSarMarkerMessage(
message,
messagesProvider,
contactsProvider,
);
},
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: Text(
'Remove marker',
style: TextStyle(color: theme.colorScheme.error),
),
subtitle: Text(
message != null
? 'This also removes the linked SAR message.'
: 'Hide this marker from the map.',
),
onTap: () async {
final confirmed = await _confirmSarMarkerRemoval(
hasMessage: message != null,
);
if (!mounted ||
!sheetContext.mounted ||
confirmed != true) {
return;
}
Navigator.pop(sheetContext);
await messagesProvider.removeSarMarkerPermanently(
marker.id,
);
},
),
],
),
),
);
},
);
}
Future<bool?> _confirmSarMarkerRemoval({required bool hasMessage}) {
return showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Remove SAR marker'),
content: Text(
hasMessage
? 'This will remove the marker and its linked chat message.'
: 'This will hide the marker from the map, even if it is not visible in chat.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(AppLocalizations.of(dialogContext)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(dialogContext)!.delete),
),
],
),
);
}
Future<void> _openSarMarkerMessage(
Message message,
MessagesProvider messagesProvider,
ContactsProvider contactsProvider,
) async {
if (message.isChannelMessage) {
final channelContact = contactsProvider.channels.where((contact) {
return contact.publicKey.length > 1 &&
contact.publicKey[1] == (message.channelIdx ?? 0);
}).firstOrNull;
messagesProvider.navigateToDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKeyHex: channelContact?.publicKeyHex,
);
} else {
Contact? destinationContact;
if (message.recipientPublicKey != null) {
destinationContact = contactsProvider.contacts.where((contact) {
return contact.publicKey.length >=
message.recipientPublicKey!.length &&
contact.publicKey.matches(message.recipientPublicKey!);
}).firstOrNull;
} else if (message.senderPublicKeyPrefix != null &&
message.senderPublicKeyPrefix!.length >= 6) {
destinationContact = contactsProvider.findContactByPrefix(
message.senderPublicKeyPrefix!,
);
}
if (destinationContact != null) {
messagesProvider.navigateToDestination(
destinationContact.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact,
recipientPublicKeyHex: destinationContact.publicKeyHex,
);
}
}
messagesProvider.navigateToMessage(message.id);
widget.onNavigateToMessages?.call();
}
/// Show SAR dialog with pre-populated location from map long press /// Show SAR dialog with pre-populated location from map long press
void _showSarDialogWithLocation(LatLng location) { void _showSarDialogWithLocation(LatLng location) {
// Create a Position object from the LatLng coordinates // Create a Position object from the LatLng coordinates
@@ -1118,9 +1288,26 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); // Required for AutomaticKeepAliveClientMixin super.build(context); // Required for AutomaticKeepAliveClientMixin
return Consumer3<ContactsProvider, MessagesProvider, DrawingProvider>( return Consumer4<
builder: (context, contactsProvider, messagesProvider, drawingProvider, child) { ContactsProvider,
final contactsWithLocation = contactsProvider.contactsWithLocation; MessagesProvider,
DrawingProvider,
MapProvider
>(
builder: (
context,
contactsProvider,
messagesProvider,
drawingProvider,
mapProvider,
child,
) {
final allContactsWithLocation = contactsProvider.contactsWithLocation;
final contactsWithLocation = mapProvider.hideRepeatersOnMap
? allContactsWithLocation
.where((contact) => !contact.isRepeater)
.toList()
: allContactsWithLocation;
// Filter SAR markers based on visibility toggle // Filter SAR markers based on visibility toggle
final allSarMarkers = messagesProvider.sarMarkers; final allSarMarkers = messagesProvider.sarMarkers;
final sarMarkers = drawingProvider.showSarMarkers final sarMarkers = drawingProvider.showSarMarkers
@@ -1718,7 +1905,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
onTap: (contact) { onTap: (contact) {
_showDetailedCompassWithContact( _showDetailedCompassWithContact(
context, context,
contactsProvider.contactsWithLocation, contactsWithLocation,
messagesProvider.sarMarkers, messagesProvider.sarMarkers,
contact, contact,
); );
@@ -1730,9 +1917,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
context: context, context: context,
mapRotation: _getMapRotation(), mapRotation: _getMapRotation(),
onTap: (marker) { onTap: (marker) {
// Navigate to the corresponding message in Messages tab _showSarMarkerActions(
messagesProvider.navigateToMessage(marker.id); marker,
widget.onNavigateToMessages?.call(); messagesProvider,
contactsProvider,
);
}, },
), ),
// User location marker with directional pointer // User location marker with directional pointer
@@ -2020,7 +2209,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
child: GestureDetector( child: GestureDetector(
onTap: () => _showDetailedCompass( onTap: () => _showDetailedCompass(
context, context,
contactsProvider.contactsWithLocation, contactsWithLocation,
messagesProvider.sarMarkers, messagesProvider.sarMarkers,
), ),
child: CompassWidget( child: CompassWidget(
@@ -2131,6 +2320,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
right: 16, right: 16,
child: Column( child: Column(
children: [ children: [
const DrawingToolbar(),
const SizedBox(height: 8),
// Hide other buttons when in drawing mode // Hide other buttons when in drawing mode
if (!drawingProvider.isDrawing) ...[ if (!drawingProvider.isDrawing) ...[
// Current Location - always center to GPS // Current Location - always center to GPS

View File

@@ -39,8 +39,9 @@ import '../l10n/app_localizations.dart';
class MessagesTab extends StatefulWidget { class MessagesTab extends StatefulWidget {
final VoidCallback? onNavigateToMap; final VoidCallback? onNavigateToMap;
final bool isActive;
const MessagesTab({super.key, this.onNavigateToMap}); const MessagesTab({super.key, this.onNavigateToMap, this.isActive = true});
@override @override
State<MessagesTab> createState() => _MessagesTabState(); State<MessagesTab> createState() => _MessagesTabState();
@@ -50,6 +51,7 @@ class _MessagesTabState extends State<MessagesTab> {
static const int _maxContactMessageBytes = 156; static const int _maxContactMessageBytes = 156;
static const int _maxChannelMessageBytes = 127; static const int _maxChannelMessageBytes = 127;
static const double _composerOverlayHeight = 148; static const double _composerOverlayHeight = 148;
static const Duration _channelAutoReadDelay = Duration(seconds: 5);
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
@@ -57,6 +59,12 @@ class _MessagesTabState extends State<MessagesTab> {
int _messageByteCount = 0; int _messageByteCount = 0;
String? _highlightedMessageId; String? _highlightedMessageId;
Timer? _highlightTimer; // Timer for clearing message highlight Timer? _highlightTimer; // Timer for clearing message highlight
Timer? _channelReadTimer;
String? _pendingChannelReadKey;
bool _suppressMentionTrigger = false;
TextRange? _activeMentionRange;
String _mentionQuery = '';
List<Contact> _mentionSuggestions = const [];
// Message destination state // Message destination state
String _destinationType = String _destinationType =
@@ -71,7 +79,7 @@ class _MessagesTabState extends State<MessagesTab> {
final VoiceRecorderService _voiceRecorder = VoiceRecorderService(); final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
bool _isRecording = false; bool _isRecording = false;
bool _isSendingVoice = false; bool _isSendingVoice = false;
static const int _maxVoicePackets = 10; static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30);
static const double _silenceRmsThreshold = 500.0; static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0; static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 2; static const int _maxInteriorSilentChunks = 2;
@@ -85,16 +93,17 @@ class _MessagesTabState extends State<MessagesTab> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_textController.addListener(_updateCharacterCount); _textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged);
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceBitrate(); _loadVoiceSettings();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForNavigationRequest(); _checkForNavigationRequest();
}); });
} }
Future<void> _loadVoiceBitrate() async { Future<void> _loadVoiceSettings() async {
final bitrate = await VoiceBitratePreferences.getBitrate(); final bitrate = await VoiceBitratePreferences.getBitrate();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -115,14 +124,24 @@ class _MessagesTabState extends State<MessagesTab> {
@override @override
void dispose() { void dispose() {
_highlightTimer?.cancel(); _highlightTimer?.cancel();
_channelReadTimer?.cancel();
_voiceStreamSub?.cancel(); _voiceStreamSub?.cancel();
_voiceRecorder.dispose(); _voiceRecorder.dispose();
_focusNode.removeListener(_handleFocusChanged);
_textController.dispose(); _textController.dispose();
_focusNode.dispose(); _focusNode.dispose();
_scrollController.dispose(); _scrollController.dispose();
super.dispose(); super.dispose();
} }
@override
void didUpdateWidget(covariant MessagesTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive != widget.isActive) {
_syncChannelAutoReadTimer(context.read<MessagesProvider>());
}
}
void _checkForNavigationRequest() { void _checkForNavigationRequest() {
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final targetMessageId = messagesProvider.targetMessageId; final targetMessageId = messagesProvider.targetMessageId;
@@ -144,9 +163,13 @@ class _MessagesTabState extends State<MessagesTab> {
void _scrollToMessage(String messageId) { void _scrollToMessage(String messageId) {
final messagesProvider = context.read<MessagesProvider>(); final messagesProvider = context.read<MessagesProvider>();
final messages = _getFilteredMessages(messagesProvider); final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final messageIndex = messages.indexWhere((m) => m.id == messageId); final messageIndex = messages.indexWhere(
(entry) => entry.message.id == messageId,
);
if (messageIndex != -1 && _scrollController.hasClients) { if (messageIndex != -1 && _scrollController.hasClients) {
// Calculate position - accounting for reverse list // Calculate position - accounting for reverse list
@@ -177,12 +200,130 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
void _handleComposerChanged() {
_updateCharacterCount();
if (_suppressMentionTrigger) {
return;
}
_updateMentionSuggestions(_textController.value);
}
void _updateCharacterCount() { void _updateCharacterCount() {
setState(() { setState(() {
_messageByteCount = utf8.encode(_textController.text).length; _messageByteCount = utf8.encode(_textController.text).length;
}); });
} }
void _handleFocusChanged() {
if (!_focusNode.hasFocus) {
_clearMentionSuggestions();
return;
}
if (_suppressMentionTrigger) {
return;
}
_updateMentionSuggestions(_textController.value);
}
void _updateMentionSuggestions(TextEditingValue value) {
final triggerRange = _getMentionTriggerRange(value);
if (triggerRange == null) {
_clearMentionSuggestions();
return;
}
final query = value.text.substring(
triggerRange.start + 1,
triggerRange.end,
);
final contacts = _buildMentionSuggestions(query);
if (!mounted) {
return;
}
setState(() {
_activeMentionRange = triggerRange;
_mentionQuery = query;
_mentionSuggestions = contacts;
});
}
void _clearMentionSuggestions() {
if (_activeMentionRange == null &&
_mentionQuery.isEmpty &&
_mentionSuggestions.isEmpty) {
return;
}
setState(() {
_activeMentionRange = null;
_mentionQuery = '';
_mentionSuggestions = const [];
});
}
TextRange? _getMentionTriggerRange(TextEditingValue value) {
if (!value.selection.isValid || !value.selection.isCollapsed) {
return null;
}
final cursorOffset = value.selection.baseOffset;
if (cursorOffset <= 0) {
return null;
}
final text = value.text;
final triggerStart = text.lastIndexOf('@', cursorOffset - 1);
if (triggerStart == -1) {
return null;
}
final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null;
if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) {
return null;
}
final query = text.substring(triggerStart + 1, cursorOffset);
if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) {
return null;
}
return TextRange(start: triggerStart, end: cursorOffset);
}
List<Contact> _buildMentionSuggestions(String query) {
final contactsProvider = context.read<ContactsProvider>();
final normalizedQuery = query.trim().toLowerCase();
final contacts =
contactsProvider.contacts
.where((contact) => contact.type == ContactType.chat)
.where((contact) {
if (normalizedQuery.isEmpty) return true;
return contact.displayName.toLowerCase().contains(
normalizedQuery,
);
})
.toList()
..sort((a, b) {
final aName = a.displayName.toLowerCase();
final bName = b.displayName.toLowerCase();
final aStarts =
normalizedQuery.isNotEmpty && aName.startsWith(normalizedQuery);
final bStarts =
normalizedQuery.isNotEmpty && bName.startsWith(normalizedQuery);
if (aStarts != bStarts) {
return aStarts ? -1 : 1;
}
return aName.compareTo(bName);
});
return contacts.take(8).toList(growable: false);
}
int get _maxMessageBytes => int get _maxMessageBytes =>
_destinationType == MessageDestinationPreferences.destinationTypeChannel _destinationType == MessageDestinationPreferences.destinationTypeChannel
? _maxChannelMessageBytes ? _maxChannelMessageBytes
@@ -338,9 +479,47 @@ class _MessagesTabState extends State<MessagesTab> {
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
void _insertReplyMention(String displayName, {TextRange? replacementRange}) {
final trimmedName = displayName.trim();
if (trimmedName.isEmpty) return;
final mention = '@[$trimmedName] ';
final value = _textController.value;
final selection = replacementRange ?? value.selection;
final hasSelection =
selection.isValid &&
selection.start >= 0 &&
selection.end >= selection.start;
final start = hasSelection ? selection.start : value.text.length;
final end = hasSelection ? selection.end : value.text.length;
final nextText = value.text.replaceRange(start, end, mention);
final nextOffset = start + mention.length;
_suppressMentionTrigger = true;
_textController.value = value.copyWith(
text: nextText,
selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty,
);
_suppressMentionTrigger = false;
_clearMentionSuggestions();
_enforceMessageByteLimit();
}
void _selectMention(Contact contact) {
_insertReplyMention(
contact.displayName,
replacementRange: _activeMentionRange,
);
_focusNode.requestFocus();
}
Future<void> _replyToMessage(Message message) async { Future<void> _replyToMessage(Message message) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
Contact? senderContact;
String? senderDisplayName;
String destinationType; String destinationType;
Contact? recipient; Contact? recipient;
@@ -360,6 +539,28 @@ class _MessagesTabState extends State<MessagesTab> {
return; return;
} }
} }
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else {
final roomRecipient = message.recipientPublicKey == null
? null
: contactsProvider.findContactByKey(message.recipientPublicKey!);
if (roomRecipient?.isRoom == true) {
destinationType = MessageDestinationPreferences.destinationTypeRoom;
recipient = roomRecipient;
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
senderDisplayName =
senderContact?.displayName ?? message.senderName?.trim();
} else { } else {
final senderPrefix = message.senderPublicKeyPrefix; final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) { if (senderPrefix == null || senderPrefix.length < 6) {
@@ -377,9 +578,15 @@ class _MessagesTabState extends State<MessagesTab> {
? MessageDestinationPreferences.destinationTypeRoom ? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact; : MessageDestinationPreferences.destinationTypeContact;
} }
}
await _onRecipientSelected(destinationType, recipient); await _onRecipientSelected(destinationType, recipient);
if (!mounted) return; if (!mounted) return;
if ((message.isChannelMessage || recipient?.isRoom == true) &&
senderDisplayName != null &&
senderDisplayName.isNotEmpty) {
_insertReplyMention(senderDisplayName);
}
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
@@ -433,6 +640,9 @@ class _MessagesTabState extends State<MessagesTab> {
return; return;
} }
_textController.clear();
_focusNode.unfocus();
try { try {
// Check destination type and send accordingly // Check destination type and send accordingly
if (_destinationType == if (_destinationType ==
@@ -463,13 +673,18 @@ class _MessagesTabState extends State<MessagesTab> {
await _sendToChannel(text, connectionProvider, messagesProvider, 0); await _sendToChannel(text, connectionProvider, messagesProvider, 0);
} }
_textController.clear();
_focusNode.unfocus();
_markCurrentDestinationAsRead(); _markCurrentDestinationAsRead();
if (!mounted) return; if (!mounted) return;
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
if (_textController.text.isEmpty) {
_textController.text = text;
_textController.selection = TextSelection.collapsed(
offset: _textController.text.length,
);
_focusNode.requestFocus();
}
ToastLogger.error(context, 'Failed to send: $e'); ToastLogger.error(context, 'Failed to send: $e');
} }
} }
@@ -783,7 +998,7 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _startVoiceRecording() async { Future<void> _startVoiceRecording() async {
if (_isSendingVoice || _isRecording) return; if (_isSendingVoice || _isRecording) return;
debugPrint('🎙️ [Voice] _startVoiceRecording called'); debugPrint('🎙️ [Voice] _startVoiceRecording called');
// Read fresh bitrate preference so settings changes apply immediately. // Read fresh voice preferences so settings changes apply immediately.
final selectedBitrate = await VoiceBitratePreferences.getBitrate(); final selectedBitrate = await VoiceBitratePreferences.getBitrate();
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -822,11 +1037,12 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedVoiceBitrate, _selectedVoiceBitrate,
); );
final packetDuration = Duration( final packetDuration = Duration(
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs, milliseconds: _activeVoiceMode!.packetDurationMs,
); );
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
debugPrint( debugPrint(
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms', '🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
); );
_recordedChunks.clear(); _recordedChunks.clear();
@@ -835,9 +1051,13 @@ class _MessagesTabState extends State<MessagesTab> {
try { try {
final stream = _voiceRecorder.startCapture( final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration, chunkDuration: packetDuration,
sampleRateHz: _activeVoiceMode!.sampleRateHz,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
enableCompressor: appProvider.isVoiceCompressorEnabled, enableCompressor: appProvider.isVoiceCompressorEnabled,
enableLimiter: appProvider.isVoiceLimiterEnabled, enableLimiter: appProvider.isVoiceLimiterEnabled,
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
); );
debugPrint('🎙️ [Voice] capture started, listening for chunks...'); debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen( _voiceStreamSub = stream.listen(
@@ -848,7 +1068,7 @@ class _MessagesTabState extends State<MessagesTab> {
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples', '🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
); );
setState(() {}); setState(() {});
if (_recordedChunks.length >= _maxVoicePackets) { if (_recordedChunks.length >= maxVoicePackets) {
debugPrint('🎙️ [Voice] max packets reached, stopping'); debugPrint('🎙️ [Voice] max packets reached, stopping');
_stopAndSendVoice(); _stopAndSendVoice();
} }
@@ -865,6 +1085,12 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
int _maxVoicePacketsForMode(VoicePacketMode mode) {
final packets =
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
return packets < 1 ? 1 : packets;
}
Future<void> _stopAndSendVoice() async { Future<void> _stopAndSendVoice() async {
if (!_isRecording) return; if (!_isRecording) return;
final trimSilenceEnabled = context final trimSilenceEnabled = context
@@ -879,9 +1105,14 @@ class _MessagesTabState extends State<MessagesTab> {
await _voiceRecorder.stopCapture(); await _voiceRecorder.stopCapture();
final rawChunks = List<Int16List>.from(_recordedChunks); final rawChunks = List<Int16List>.from(_recordedChunks);
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks; final trimmedChunks = trimSilenceEnabled
? _trimSilence(rawChunks)
: rawChunks;
final sessionId = _currentVoiceSessionId; final sessionId = _currentVoiceSessionId;
final mode = _activeVoiceMode; final mode = _activeVoiceMode;
final chunks = mode == null
? trimmedChunks
: _prepareChunksForSending(trimmedChunks, mode);
_recordedChunks.clear(); _recordedChunks.clear();
debugPrint( debugPrint(
@@ -1070,6 +1301,13 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.markMessageSent(msgId, 0, 0); messagesProvider.markMessageSent(msgId, 0, 0);
} }
List<Int16List> _prepareChunksForSending(
List<Int16List> chunks,
VoicePacketMode mode,
) {
return chunks;
}
List<Int16List> _trimSilence(List<Int16List> chunks) { List<Int16List> _trimSilence(List<Int16List> chunks) {
if (chunks.isEmpty) return chunks; if (chunks.isEmpty) return chunks;
@@ -1099,16 +1337,28 @@ class _MessagesTabState extends State<MessagesTab> {
bool _isSilentChunk(Int16List chunk) { bool _isSilentChunk(Int16List chunk) {
if (chunk.isEmpty) return true; if (chunk.isEmpty) return true;
final rms = _chunkRms(chunk);
final peak = _chunkPeak(chunk);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
}
double _chunkRms(Int16List chunk) {
var sumSquares = 0.0; var sumSquares = 0.0;
for (final sample in chunk) {
sumSquares += sample * sample;
}
return math.sqrt(sumSquares / chunk.length);
}
int _chunkPeak(Int16List chunk) {
var peak = 0; var peak = 0;
for (final sample in chunk) { for (final sample in chunk) {
final absSample = sample.abs(); final absSample = sample.abs();
if (absSample > peak) peak = absSample; if (absSample > peak) {
sumSquares += sample * sample; peak = absSample;
} }
}
final rms = math.sqrt(sumSquares / chunk.length); return peak;
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
} }
bool _isPublicChannelSelected() { bool _isPublicChannelSelected() {
@@ -1643,12 +1893,64 @@ class _MessagesTabState extends State<MessagesTab> {
} }
void _markCurrentDestinationAsRead() { void _markCurrentDestinationAsRead() {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
context.read<MessagesProvider>().markDestinationAsRead( context.read<MessagesProvider>().markDestinationAsRead(
destinationType: _destinationType, destinationType: _destinationType,
contact: _selectedRecipient, contact: _selectedRecipient,
); );
} }
void _syncChannelAutoReadTimer(MessagesProvider messagesProvider) {
if (!widget.isActive ||
_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
return;
}
final channelIdx = _selectedRecipient?.publicKey[1] ?? 0;
final unreadCount = messagesProvider.getUnreadCountForChannel(channelIdx);
if (unreadCount <= 0) {
_channelReadTimer?.cancel();
_pendingChannelReadKey = null;
return;
}
final nextKey = '$channelIdx:$unreadCount';
if (_pendingChannelReadKey == nextKey &&
_channelReadTimer?.isActive == true) {
return;
}
_channelReadTimer?.cancel();
_pendingChannelReadKey = nextKey;
_channelReadTimer = Timer(_channelAutoReadDelay, () {
if (!mounted || !widget.isActive) {
return;
}
if (_destinationType !=
MessageDestinationPreferences.destinationTypeChannel) {
return;
}
final currentChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
if (currentChannelIdx != channelIdx) {
return;
}
final latestProvider = context.read<MessagesProvider>();
if (latestProvider.getUnreadCountForChannel(channelIdx) <= 0) {
return;
}
_markCurrentDestinationAsRead();
});
}
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) { List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages // Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100); final allMessages = messagesProvider.getRecentMessages(count: 100);
@@ -1741,7 +2043,10 @@ class _MessagesTabState extends State<MessagesTab> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MessagesProvider>( return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) { builder: (context, messagesProvider, child) {
final messages = _getFilteredMessages(messagesProvider); _syncChannelAutoReadTimer(messagesProvider);
final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final bottomInset = MediaQuery.of(context).viewPadding.bottom; final bottomInset = MediaQuery.of(context).viewPadding.bottom;
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0; final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
@@ -1779,6 +2084,9 @@ class _MessagesTabState extends State<MessagesTab> {
bottomPadding: composerBottomPadding, bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(), destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context), destinationAvatar: _buildDestinationAvatar(context),
mentionSuggestions: _mentionSuggestions,
mentionQuery: _mentionQuery,
onMentionSelected: _selectMention,
onShowComposerActions: _showComposerActions, onShowComposerActions: _showComposerActions,
onShowRecipientSelector: _showRecipientSelector, onShowRecipientSelector: _showRecipientSelector,
onStartVoiceRecording: _startVoiceRecording, onStartVoiceRecording: _startVoiceRecording,

View File

@@ -777,13 +777,7 @@ class _DecodedRouteSection extends StatelessWidget {
), ),
) )
.toList(); .toList();
final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first; return _RouteSection(route: decodedRoute, path: resolvedPath);
return _RouteSection(
route: decodedRoute,
path: resolvedPath,
originalSender: originalSender,
);
}, },
); );
} }
@@ -827,13 +821,8 @@ class _DecodedRouteSection extends StatelessWidget {
class _RouteSection extends StatelessWidget { class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route; final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path; final List<ResolvedNodeHash> path;
final ResolvedNodeHash? originalSender;
const _RouteSection({ const _RouteSection({required this.route, required this.path});
required this.route,
required this.path,
required this.originalSender,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -879,12 +868,6 @@ class _RouteSection extends StatelessWidget {
value: value:
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}', '${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
), ),
if (originalSender != null)
_FactCard(
icon: Icons.person_pin_circle,
label: 'Original sender',
value: _nodeLabel(originalSender!),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@@ -0,0 +1,775 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
class RepeatersMapScreen extends StatefulWidget {
const RepeatersMapScreen({super.key});
@override
State<RepeatersMapScreen> createState() => _RepeatersMapScreenState();
}
class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
static const LatLng _fallbackCenter = LatLng(46.0569, 14.5058);
static const double _fallbackZoom = 7;
static const double _myLocationZoom = 14;
final flutter_map.MapController _mapController = flutter_map.MapController();
List<MeshMapNode> _onlineRepeaters = const [];
bool _isLoading = true;
bool _didFitCamera = false;
String? _error;
double _currentZoom = _fallbackZoom;
LatLng? _myLocation;
final Set<String> _addingRepeaters = <String>{};
@override
void initState() {
super.initState();
_loadRepeaters();
}
Future<void> _loadRepeaters({bool forceRefresh = false}) async {
setState(() {
_isLoading = true;
_error = null;
if (forceRefresh) {
_didFitCamera = false;
}
});
try {
final nodes = await MeshMapNodesService.fetchNodes(
forceRefresh: forceRefresh,
);
final repeaters = nodes.where(MeshMapNodesService.isRepeater).toList();
if (!mounted) return;
setState(() {
_onlineRepeaters = repeaters;
_isLoading = false;
});
_refreshMyLocation();
} catch (error) {
final cached = await MeshMapNodesService.loadCachedNodes();
final repeaters = cached.where(MeshMapNodesService.isRepeater).toList();
if (!mounted) return;
setState(() {
_onlineRepeaters = repeaters;
_isLoading = false;
_error = repeaters.isEmpty
? 'Unable to load repeaters right now.'
: 'Showing cached repeaters. Pull to retry.';
});
_refreshMyLocation();
}
}
List<_MapRepeater> _mergeRepeaters({
required List<MeshMapNode> onlineNodes,
required List<Contact> contactRepeaters,
}) {
final merged = <String, _MapRepeater>{};
for (final node in onlineNodes) {
merged[node.publicKey] = _MapRepeater(
publicKey: node.publicKey,
name: node.name,
latitude: node.latitude,
longitude: node.longitude,
isFromContacts: false,
isFromOnline: true,
);
}
for (final contact in contactRepeaters) {
final location = contact.displayLocation;
if (location == null) {
continue;
}
final key = contact.publicKeyHex.toLowerCase();
final existing = merged[key];
merged[key] = _MapRepeater(
publicKey: key,
name: contact.displayName.isNotEmpty
? contact.displayName
: existing?.name ?? 'Unknown repeater',
latitude: existing?.latitude ?? location.latitude,
longitude: existing?.longitude ?? location.longitude,
isFromContacts: true,
isFromOnline: existing?.isFromOnline ?? false,
);
}
final repeaters = merged.values.toList()
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return repeaters;
}
Future<void> _refreshMyLocation() async {
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 0,
),
);
if (!mounted) return;
setState(() {
_myLocation = LatLng(position.latitude, position.longitude);
});
} catch (_) {
// Best effort only.
}
}
void _fitCameraIfNeeded(List<_MapRepeater> repeaters) {
if (_didFitCamera || repeaters.isEmpty || !mounted) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _didFitCamera || repeaters.isEmpty) {
return;
}
if (repeaters.length == 1) {
final repeater = repeaters.first;
_mapController.move(LatLng(repeater.latitude, repeater.longitude), 13);
_currentZoom = 13;
} else {
final bounds = flutter_map.LatLngBounds.fromPoints(
repeaters
.map((node) => LatLng(node.latitude, node.longitude))
.toList(),
);
_mapController.fitCamera(
flutter_map.CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(48),
),
);
}
_didFitCamera = true;
});
}
void _showRepeaterDetails(_MapRepeater repeater) {
final isAdding = _addingRepeaters.contains(repeater.publicKey);
final canAdd = !repeater.isFromContacts;
final isConnected = context
.read<ConnectionProvider>()
.deviceInfo
.isConnected;
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
repeater.name,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
_InfoLine(
label: 'Public key',
value: repeater.publicKey,
monospace: true,
),
_InfoLine(
label: 'Coordinates',
value:
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
),
_InfoLine(label: 'Source', value: repeater.sourceLabel),
const SizedBox(height: 12),
if (canAdd)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: !isConnected || isAdding
? null
: () async {
Navigator.of(context).pop();
await _addRepeaterToContacts(repeater);
},
icon: isAdding
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.person_add_alt_1),
label: Text(
!isConnected
? 'Connect device to add'
: 'Add to contacts',
),
),
)
else
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: null,
icon: const Icon(Icons.check_circle),
label: const Text('Already in contacts'),
),
),
],
),
),
),
);
}
Future<void> _addRepeaterToContacts(_MapRepeater repeater) async {
if (_addingRepeaters.contains(repeater.publicKey)) {
return;
}
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connect to a device before adding contacts'),
),
);
return;
}
setState(() {
_addingRepeaters.add(repeater.publicKey);
});
try {
await connectionProvider.getContact(_hexToBytes(repeater.publicKey));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${repeater.name} added to contacts')),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add ${repeater.name}: $error')),
);
} finally {
if (mounted) {
setState(() {
_addingRepeaters.remove(repeater.publicKey);
});
}
}
}
Uint8List _hexToBytes(String hex) {
final normalized = hex.replaceAll(':', '').trim().toLowerCase();
return Uint8List.fromList(
List<int>.generate(
normalized.length ~/ 2,
(index) => int.parse(
normalized.substring(index * 2, index * 2 + 2),
radix: 16,
),
),
);
}
@override
Widget build(BuildContext context) {
final contactsProvider = context.watch<ContactsProvider>();
final repeaters = _mergeRepeaters(
onlineNodes: _onlineRepeaters,
contactRepeaters: contactsProvider.repeaters,
);
_fitCameraIfNeeded(repeaters);
final clusters = _buildClusters(repeaters);
final markers = clusters.map((cluster) {
final markerColor = switch (cluster.status) {
_ClusterStatus.contactsOnly => const Color(0xFF7C3AED),
_ClusterStatus.onlineOnly => const Color(0xFFE8681D),
_ClusterStatus.both => const Color(0xFF1B8F4F),
_ClusterStatus.mixed => const Color(0xFF2563EB),
};
if (cluster.members.length == 1) {
final repeater = cluster.members.first;
return flutter_map.Marker(
point: cluster.center,
width: 80,
height: 100,
child: GestureDetector(
onTap: () => _showRepeaterDetails(repeater),
child: _RepeaterMarker(color: markerColor, label: repeater.name),
),
);
}
return flutter_map.Marker(
point: cluster.center,
width: 48,
height: 48,
child: GestureDetector(
onTap: () => _zoomToCluster(cluster),
child: _ClusterMarker(
color: markerColor,
count: cluster.members.length,
),
),
);
}).toList();
final allMarkers = <flutter_map.Marker>[
...markers,
if (_myLocation != null)
flutter_map.Marker(
point: _myLocation!,
width: 24,
height: 24,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
),
),
];
return Scaffold(
appBar: AppBar(
title: const Text('Repeaters Map'),
actions: [
IconButton(
onPressed: _isLoading
? null
: () => _loadRepeaters(forceRefresh: true),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
body: Stack(
children: [
flutter_map.FlutterMap(
mapController: _mapController,
options: flutter_map.MapOptions(
initialCenter: _fallbackCenter,
initialZoom: _fallbackZoom,
onPositionChanged: (camera, hasGesture) {
final nextZoom = camera.zoom;
if (nextZoom != _currentZoom && mounted) {
setState(() {
_currentZoom = nextZoom;
});
}
},
),
children: [
flutter_map.TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'app.meshcore.sar',
),
flutter_map.MarkerLayer(markers: allMarkers),
],
),
if (_error != null)
Positioned(
top: 16,
left: 16,
right: 16,
child: Material(
elevation: 2,
borderRadius: BorderRadius.circular(14),
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Text(_error!),
),
),
),
if (_isLoading) const Center(child: CircularProgressIndicator()),
if (!_isLoading && repeaters.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
_error ?? 'No repeaters available.',
textAlign: TextAlign.center,
),
),
),
if (repeaters.isNotEmpty)
Positioned(
left: 16,
top: _error == null ? 16 : 72,
child: Material(
elevation: 2,
borderRadius: BorderRadius.circular(14),
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: const [
_LegendRow(
color: Color(0xFF7C3AED),
label: 'From contacts',
),
SizedBox(height: 8),
_LegendRow(
color: Color(0xFFE8681D),
label: 'Online only',
),
SizedBox(height: 8),
_LegendRow(color: Color(0xFF1B8F4F), label: 'In both'),
],
),
),
),
),
if (repeaters.isNotEmpty)
Positioned(
left: 16,
right: 16,
bottom: 16,
child: Material(
elevation: 3,
borderRadius: BorderRadius.circular(16),
color: Theme.of(context).colorScheme.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Text(
'${repeaters.length} repeaters',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _jumpToMyLocation,
tooltip: 'My location',
child: const Icon(Icons.my_location),
),
);
}
Future<void> _jumpToMyLocation() async {
await _refreshMyLocation();
final myLocation = _myLocation;
if (!mounted || myLocation == null) {
return;
}
_mapController.move(myLocation, _myLocationZoom);
setState(() {
_currentZoom = _myLocationZoom;
});
}
List<_RepeaterCluster> _buildClusters(List<_MapRepeater> repeaters) {
if (repeaters.isEmpty) {
return const [];
}
if (_currentZoom >= 11.5) {
return repeaters
.map(
(repeater) => _RepeaterCluster(
members: [repeater],
center: LatLng(repeater.latitude, repeater.longitude),
status: repeater.clusterStatus,
),
)
.toList();
}
final cellSize = _gridSizeForZoom(_currentZoom);
final buckets = <String, List<_MapRepeater>>{};
for (final repeater in repeaters) {
final latBucket = (repeater.latitude / cellSize).floor();
final lonBucket = (repeater.longitude / cellSize).floor();
final key = '$latBucket:$lonBucket';
buckets.putIfAbsent(key, () => <_MapRepeater>[]).add(repeater);
}
return buckets.values.map((members) {
final centerLat =
members.map((node) => node.latitude).reduce((a, b) => a + b) /
members.length;
final centerLon =
members.map((node) => node.longitude).reduce((a, b) => a + b) /
members.length;
final statuses = members.map((node) => node.clusterStatus).toSet();
return _RepeaterCluster(
members: members,
center: LatLng(centerLat, centerLon),
status: statuses.length == 1 ? statuses.first : _ClusterStatus.mixed,
);
}).toList();
}
double _gridSizeForZoom(double zoom) {
if (zoom < 5) return 3.0;
if (zoom < 7) return 1.5;
if (zoom < 9) return 0.7;
if (zoom < 10.5) return 0.28;
return 0.12;
}
void _zoomToCluster(_RepeaterCluster cluster) {
if (cluster.members.isEmpty) {
return;
}
final bounds = flutter_map.LatLngBounds.fromPoints(
cluster.members
.map((node) => LatLng(node.latitude, node.longitude))
.toList(),
);
_mapController.fitCamera(
flutter_map.CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(64),
maxZoom: (_currentZoom + 2).clamp(8, 14),
),
);
}
}
class _InfoLine extends StatelessWidget {
final String label;
final String value;
final bool monospace;
const _InfoLine({
required this.label,
required this.value,
this.monospace = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 92,
child: Text(
label,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
),
Expanded(
child: Text(
value,
style: monospace
? const TextStyle(fontFamily: 'monospace')
: null,
),
),
],
),
);
}
}
class _LegendRow extends StatelessWidget {
final Color color;
final String label;
const _LegendRow({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Text(label),
],
);
}
}
class _RepeaterMarker extends StatelessWidget {
final Color color;
final String label;
const _RepeaterMarker({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(6),
child: const Icon(Icons.router, color: Colors.white, size: 18),
),
const SizedBox(height: 2),
Container(
constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
),
),
],
);
}
}
class _ClusterMarker extends StatelessWidget {
final Color color;
final int count;
const _ClusterMarker({required this.color, required this.count});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.22),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
alignment: Alignment.center,
child: Text(
'$count',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 14,
),
),
);
}
}
enum _ClusterStatus { contactsOnly, onlineOnly, both, mixed }
class _RepeaterCluster {
final List<_MapRepeater> members;
final LatLng center;
final _ClusterStatus status;
const _RepeaterCluster({
required this.members,
required this.center,
required this.status,
});
}
class _MapRepeater {
final String publicKey;
final String name;
final double latitude;
final double longitude;
final bool isFromContacts;
final bool isFromOnline;
const _MapRepeater({
required this.publicKey,
required this.name,
required this.latitude,
required this.longitude,
required this.isFromContacts,
required this.isFromOnline,
});
String get sourceLabel {
if (isFromContacts && isFromOnline) return 'Contacts and online';
if (isFromContacts) return 'Contacts';
return 'Online';
}
_ClusterStatus get clusterStatus {
if (isFromContacts && isFromOnline) return _ClusterStatus.both;
if (isFromContacts) return _ClusterStatus.contactsOnly;
return _ClusterStatus.onlineOnly;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,22 +14,23 @@ import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart'; import '../providers/messages_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart'; import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart'; import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../services/image_codec_service.dart'; import '../services/image_codec_service.dart';
import '../services/developer_mode_service.dart'; import '../services/developer_mode_service.dart';
import '../services/notification_service.dart';
import '../utils/sample_data_generator.dart'; import '../utils/sample_data_generator.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../widgets/connection_mode_selector.dart';
import '../widgets/update_dialog.dart'; import '../widgets/update_dialog.dart';
import 'sar_template_management_screen.dart'; import 'sar_template_management_screen.dart';
import 'welcome_wizard_screen.dart'; import 'welcome_wizard_screen.dart';
@@ -73,6 +74,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _fastLocationUpdatesEnabled = false; bool _fastLocationUpdatesEnabled = false;
double _fastLocationMovementThresholdMeters = 10.0; double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10; int _fastLocationActiveCadenceSeconds = 10;
bool _rotateMapWithHeading = false;
bool _showMapDebugInfo = false;
bool _openMapInFullscreen = false;
bool _messageNotificationsEnabled = true;
bool _sarNotificationsEnabled = true;
bool _discoveryNotificationsEnabled = true;
bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true;
bool _isDeveloperModeEnabled = false; bool _isDeveloperModeEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt; DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false; bool _isClearingOnlineTraceCache = false;
@@ -88,12 +97,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadPackageInfo(); _loadPackageInfo();
_initializeLocationService(); _initializeLocationService();
_loadRxTxPreference(); _loadRxTxPreference();
_loadVoiceBitratePreference(); _loadVoicePreferences();
_loadRouteHashSizePreference(); _loadRouteHashSizePreference();
_loadImagePreferences(); _loadImagePreferences();
_loadFastLocationSettings(); _loadFastLocationSettings();
_loadDeveloperMode(); _loadDeveloperMode();
_loadOnlineTraceCacheStatus(); _loadOnlineTraceCacheStatus();
_loadMapPreferences();
_loadNotificationPreferences();
} }
@override @override
@@ -131,6 +142,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
}); });
} }
Future<void> _loadNotificationPreferences() async {
final service = NotificationService();
await service.initialize();
if (!mounted) return;
setState(() {
_messageNotificationsEnabled = service.messageNotificationsEnabled;
_sarNotificationsEnabled = service.sarNotificationsEnabled;
_discoveryNotificationsEnabled = service.discoveryNotificationsEnabled;
_updateNotificationsEnabled = service.updateNotificationsEnabled;
_muteForegroundNotifications = service.muteForegroundNotifications;
});
}
Future<void> _loadOnlineTraceCacheStatus() async { Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt(); final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return; if (!mounted) return;
@@ -178,7 +202,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
await prefs.setBool('show_rx_tx_indicators', value); await prefs.setBool('show_rx_tx_indicators', value);
} }
Future<void> _loadVoiceBitratePreference() async { Future<void> _loadMapPreferences() async {
final prefs = await SharedPreferences.getInstance();
if (!mounted) return;
setState(() {
_rotateMapWithHeading = prefs.getBool('map_rotate_with_heading') ?? false;
_showMapDebugInfo = prefs.getBool('map_show_debug_info') ?? false;
_openMapInFullscreen = prefs.getBool('map_fullscreen') ?? false;
});
}
Future<void> _saveMapPreference(String key, bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(key, value);
}
Future<void> _loadVoicePreferences() async {
final value = await VoiceBitratePreferences.getBitrate(); final value = await VoiceBitratePreferences.getBitrate();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -981,6 +1020,88 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
_buildSectionHeader('Notifications'),
_buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.chat_bubble_outline),
title: const Text('Message notifications'),
subtitle: const Text(
'Notify for incoming direct and channel messages',
),
value: _messageNotificationsEnabled,
onChanged: (value) async {
setState(() {
_messageNotificationsEnabled = value;
});
await NotificationService().setMessageNotificationsEnabled(
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.warning_amber_outlined),
title: const Text('SAR alerts'),
subtitle: const Text(
'Notify for incoming SAR markers such as found person or fire',
),
value: _sarNotificationsEnabled,
onChanged: (value) async {
setState(() {
_sarNotificationsEnabled = value;
});
await NotificationService().setSarNotificationsEnabled(value);
},
),
SwitchListTile(
secondary: const Icon(Icons.contact_page_outlined),
title: const Text('Discovery notifications'),
subtitle: const Text(
'Notify when new contacts appear in Discovery',
),
value: _discoveryNotificationsEnabled,
onChanged: (value) async {
setState(() {
_discoveryNotificationsEnabled = value;
});
await NotificationService().setDiscoveryNotificationsEnabled(
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.system_update),
title: const Text('Update notifications'),
subtitle: const Text(
'Notify when a newer app version is available',
),
value: _updateNotificationsEnabled,
onChanged: (value) async {
setState(() {
_updateNotificationsEnabled = value;
});
await NotificationService().setUpdateNotificationsEnabled(
value,
);
},
),
SwitchListTile(
secondary: const Icon(Icons.visibility_off_outlined),
title: const Text('Mute while app is open'),
subtitle: const Text(
'Do not show local notifications while the app is in the foreground',
),
value: _muteForegroundNotifications,
onChanged: (value) async {
setState(() {
_muteForegroundNotifications = value;
});
await NotificationService().setMuteForegroundNotifications(
value,
);
},
),
]),
_buildSectionHeader('Navigation'), _buildSectionHeader('Navigation'),
_buildSettingsCard([ _buildSettingsCard([
Consumer<AppProvider>( Consumer<AppProvider>(
@@ -1026,19 +1147,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Messaging'), _buildSectionHeader('Messaging'),
_buildSettingsCard([ _buildSettingsCard([
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.person_add_alt_1),
title: const Text('Auto-add discovered contacts'),
subtitle: const Text(
'Automatically fetch and add new contacts when they are discovered',
),
value: appProvider.autoAddDiscoveredContacts,
onChanged: (value) async {
await appProvider.toggleAutoAddDiscoveredContacts(value);
},
),
),
ListTile( ListTile(
leading: const Icon(Icons.alt_route), leading: const Icon(Icons.alt_route),
title: const Text('Route path byte size'), title: const Text('Route path byte size'),
@@ -1155,6 +1263,88 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
_buildSectionHeader('Map'),
_buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.explore),
title: const Text('Rotate map with heading'),
subtitle: const Text(
'Rotate the map based on your compass or movement heading',
),
value: _rotateMapWithHeading,
onChanged: (value) async {
setState(() {
_rotateMapWithHeading = value;
});
await _saveMapPreference('map_rotate_with_heading', value);
},
),
SwitchListTile(
secondary: const Icon(Icons.bug_report_outlined),
title: const Text('Show map debug info'),
subtitle: const Text(
'Display extra map diagnostics and internal state overlays',
),
value: _showMapDebugInfo,
onChanged: (value) async {
setState(() {
_showMapDebugInfo = value;
});
await _saveMapPreference('map_show_debug_info', value);
},
),
SwitchListTile(
secondary: const Icon(Icons.fullscreen),
title: const Text('Open map in fullscreen'),
subtitle: const Text(
'Start the map tab in fullscreen mode by default',
),
value: _openMapInFullscreen,
onChanged: (value) async {
setState(() {
_openMapInFullscreen = value;
});
await _saveMapPreference('map_fullscreen', value);
},
),
Consumer<DrawingProvider>(
builder: (context, drawingProvider, child) => SwitchListTile(
secondary: const Icon(Icons.fmd_good_outlined),
title: const Text('Show SAR markers'),
subtitle: const Text('Display SAR markers on the main map'),
value: drawingProvider.showSarMarkers,
onChanged: (value) {
drawingProvider.toggleSarMarkers();
},
),
),
Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: const Icon(Icons.timeline),
title: const Text('Show all contact trails'),
subtitle: const Text(
'Display location trails for all contacts that have history',
),
value: mapProvider.showAllContactTrails,
onChanged: (value) async {
await mapProvider.toggleAllContactTrails();
},
),
),
Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: const Icon(Icons.router_outlined),
title: const Text('Hide repeaters on map'),
subtitle: const Text(
'Hide repeater contacts from the main map view',
),
value: mapProvider.hideRepeatersOnMap,
onChanged: (value) async {
await mapProvider.setHideRepeatersOnMap(value);
},
),
),
]),
_buildSectionHeader('Voice'), _buildSectionHeader('Voice'),
Consumer2<AppProvider, ConnectionProvider>( Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) => builder: (context, appProvider, connectionProvider, child) =>
@@ -1164,6 +1354,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled, bandPassEnabled: appProvider.isVoiceBandPassFilterEnabled,
compressorEnabled: appProvider.isVoiceCompressorEnabled, compressorEnabled: appProvider.isVoiceCompressorEnabled,
limiterEnabled: appProvider.isVoiceLimiterEnabled, limiterEnabled: appProvider.isVoiceLimiterEnabled,
autoGainEnabled: appProvider.isVoiceAutoGainEnabled,
echoCancellationEnabled:
appProvider.isVoiceEchoCancellationEnabled,
noiseSuppressionEnabled:
appProvider.isVoiceNoiseSuppressionEnabled,
silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled, silenceTrimEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
), ),
), ),
@@ -1210,6 +1405,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.auto_fix_high),
title: const Text('Mic auto gain'),
subtitle: const Text('Lets the recorder adjust input level'),
value: appProvider.isVoiceAutoGainEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceAutoGainEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.hearing_disabled),
title: const Text('Echo cancellation'),
subtitle: const Text(
'Uses recorder echo cancellation if available',
),
value: appProvider.isVoiceEchoCancellationEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceEchoCancellationEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.noise_control_off),
title: const Text('Noise suppression'),
subtitle: const Text(
'Uses recorder noise suppression if available',
),
value: appProvider.isVoiceNoiseSuppressionEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceNoiseSuppressionEnabled(value);
},
),
),
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut), secondary: const Icon(Icons.content_cut),
@@ -1321,11 +1553,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
if (!kIsWeb) ...[
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
],
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection), _buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([ _buildSettingsCard([
SwitchListTile( SwitchListTile(
@@ -1648,7 +1875,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), ),
Switch.adaptive( Switch(
value: _showCurrentImagePreview, value: _showCurrentImagePreview,
onChanged: (value) { onChanged: (value) {
setState(() => _showCurrentImagePreview = value); setState(() => _showCurrentImagePreview = value);
@@ -1729,6 +1956,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
required bool bandPassEnabled, required bool bandPassEnabled,
required bool compressorEnabled, required bool compressorEnabled,
required bool limiterEnabled, required bool limiterEnabled,
required bool autoGainEnabled,
required bool echoCancellationEnabled,
required bool noiseSuppressionEnabled,
required bool silenceTrimEnabled, required bool silenceTrimEnabled,
}) { }) {
final supported = VoiceBitratePreferences.supportedBitrates; final supported = VoiceBitratePreferences.supportedBitrates;
@@ -1741,6 +1971,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
(bandPassEnabled ? 1 : 0) + (bandPassEnabled ? 1 : 0) +
(compressorEnabled ? 1 : 0) + (compressorEnabled ? 1 : 0) +
(limiterEnabled ? 1 : 0) + (limiterEnabled ? 1 : 0) +
(autoGainEnabled ? 1 : 0) +
(echoCancellationEnabled ? 1 : 0) +
(noiseSuppressionEnabled ? 1 : 0) +
(silenceTrimEnabled ? 1 : 0); (silenceTrimEnabled ? 1 : 0);
final radioBw = connectionProvider.deviceInfo.radioBw; final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf; final radioSf = connectionProvider.deviceInfo.radioSf;
@@ -1748,7 +1981,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final bwHz = _resolveBandwidthHz(radioBw); final bwHz = _resolveBandwidthHz(radioBw);
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate); final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
const voicePreviewMs = 10000; // 10-second reference clip const voicePreviewMs = 10000; // 10-second reference clip
final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs; final packetDurationMs = voiceMode.packetDurationMs;
final voicePacketCount = final voicePacketCount =
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs; (voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
final voiceDirect = estimateVoiceTransmitDuration( final voiceDirect = estimateVoiceTransmitDuration(
@@ -1783,7 +2016,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Bitrate: $bitrate bps', 'Codec: ${voiceMode.label} · $bitrate bps',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -1830,6 +2063,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _voiceStatChip(
label: 'Auto gain',
enabled: autoGainEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Echo cancel',
enabled: echoCancellationEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Noise suppress',
enabled: noiseSuppressionEnabled,
),
),
],
),
const SizedBox(height: 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -1842,7 +2100,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Processing enabled: $enabledCount/4', 'Processing enabled: $enabledCount/7',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],

View File

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

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -58,6 +59,16 @@ class BackgroundLocationService {
return false; return false;
} }
if (Platform.isIOS && permission != LocationPermission.always) {
permission = await Geolocator.requestPermission();
if (permission != LocationPermission.always) {
debugPrint(
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
);
return false;
}
}
// Save settings // Save settings
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, true); await prefs.setBool(_prefKeyEnabled, true);
@@ -68,8 +79,7 @@ class BackgroundLocationService {
try { try {
_positionSubscription = _positionSubscription =
Geolocator.getPositionStream( Geolocator.getPositionStream(
locationSettings: LocationSettings( locationSettings: _buildLocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceThreshold.toInt(), distanceFilter: distanceThreshold.toInt(),
), ),
).listen((Position position) async { ).listen((Position position) async {
@@ -171,4 +181,22 @@ class BackgroundLocationService {
await startTracking(distanceThreshold: distance); await startTracking(distanceThreshold: distance);
} }
} }
LocationSettings _buildLocationSettings({int distanceFilter = 10}) {
if (Platform.isIOS) {
return AppleSettings(
accuracy: LocationAccuracy.best,
activityType: ActivityType.fitness,
allowBackgroundLocationUpdates: true,
distanceFilter: distanceFilter,
pauseLocationUpdatesAutomatically: false,
showBackgroundLocationIndicator: true,
);
}
return LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceFilter,
);
}
} }

View File

@@ -5,6 +5,76 @@ import 'package:meshcore_client/meshcore_client.dart';
/// Cayenne LPP (Low Power Payload) data parser /// Cayenne LPP (Low Power Payload) data parser
/// Used for decoding telemetry sensor data from MeshCore devices /// Used for decoding telemetry sensor data from MeshCore devices
class CayenneLppParser { class CayenneLppParser {
static const int _selfTelemetryChannel = 1;
static const int _lppGenericSensor = 100;
static const int _lppCurrent = 117;
static const int _lppFrequency = 118;
static const int _lppPercentage = 120;
static const int _lppAltitude = 121;
static const int _lppConcentration = 125;
static const int _lppPower = 128;
static const int _lppSpeed = 129;
static const int _lppDistance = 130;
static const int _lppEnergy = 131;
static const int _lppDirection = 132;
static const int _lppUnixTime = 133;
static const int _lppColour = 135;
static const int _lppGust = 137;
static const int _lppDewPoint = 138;
static const int _lppRain = 139;
static const int _lppSwitch = 142;
static const int _lppBinaryBool = 143;
static const int _lppBinaryPowerSwitch = 144;
static const int _lppBinaryOpen = 145;
static const int _lppBinaryBatteryLow = 146;
static const int _lppBinaryCharging = 147;
static const int _lppBinaryCarbonMonoxide = 148;
static const int _lppBinaryCold = 149;
static const int _lppBinaryConnectivity = 150;
static const int _lppBinaryDoor = 151;
static const int _lppBinaryGarageDoor = 152;
static const int _lppBinaryGas = 153;
static const int _lppBinaryHeat = 154;
static const int _lppBinaryLight = 155;
static const int _lppBinaryLock = 156;
static const int _lppBinaryMoisture = 157;
static const int _lppBinaryMotion = 158;
static const int _lppBinaryMoving = 159;
static const int _lppBinaryOccupancy = 160;
static const int _lppBinaryPlug = 161;
static const int _lppBinaryPresence = 162;
static const int _lppBinaryProblem = 163;
static const int _lppBinaryRunning = 164;
static const int _lppBinarySafety = 165;
static const int _lppBinarySmoke = 166;
static const int _lppBinarySound = 167;
static const int _lppBinaryTamper = 168;
static const int _lppBinaryVibration = 169;
static const int _lppBinaryWindow = 170;
static const int _lppButtonEvent = 171;
static const int _lppDimmer = 172;
static const int _lppUv = 173;
static const int _lppLightLevel = 174;
static const int _lppPm25 = 175;
static const int _lppPm10 = 176;
static const int _lppCo2 = 177;
static const int _lppTvoc = 178;
static const int _lppRpm = 179;
static const int _lppConductivity = 180;
static const int _lppRotation = 181;
static const int _lppDuration = 182;
static const int _lppAcceleration = 183;
static const int _lppGyroRate = 184;
static const int _lppVolume = 185;
static const int _lppFlowRate = 186;
static const int _lppVolumeStorage = 187;
static const int _lppWater = 188;
static const int _lppGasVolume = 189;
static const int _lppMass = 190;
static const int _lppSignedSpeed = 191;
static const int _lppSignedPower = 192;
static const int _lppSignedCurrent = 193;
/// Parse Cayenne LPP data into ContactTelemetry /// Parse Cayenne LPP data into ContactTelemetry
static ContactTelemetry parse(Uint8List data) { static ContactTelemetry parse(Uint8List data) {
debugPrint(' [CayenneLPP] Parsing LPP data...'); debugPrint(' [CayenneLPP] Parsing LPP data...');
@@ -25,7 +95,8 @@ class CayenneLppParser {
int fieldCount = 0; int fieldCount = 0;
while (reader.hasRemaining) { while (reader.hasRemaining) {
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) { if (fieldCount > 0 &&
_isZeroPaddedTail(data, reader.remainingBytesCount)) {
debugPrint( debugPrint(
' Detected zero-padded telemetry tail, stopping parse at position ' ' Detected zero-padded telemetry tail, stopping parse at position '
'${data.length - reader.remainingBytesCount}', '${data.length - reader.remainingBytesCount}',
@@ -65,14 +136,16 @@ class CayenneLppParser {
final value = rawValue / 100.0; final value = rawValue / 100.0;
debugPrint(' Analog Input (raw): $rawValue'); debugPrint(' Analog Input (raw): $rawValue');
debugPrint(' Analog Input (volts): ${value}V'); debugPrint(' Analog Input (volts): ${value}V');
extraSensorData['analog_input_$channel'] = value; if (_isBatteryChannel(channel)) {
// If this is a battery reading
if (channel == 0 || channel == 1) {
batteryMilliVolts = value * 1000; batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value); batteryPercentage = _calculateBatteryPercentage(value);
extraSensorData[_sourceChannelKey('battery')] = channel;
extraSensorData[_sourceChannelKey('voltage')] = channel;
debugPrint( debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
); );
} else {
extraSensorData['analog_input_$channel'] = value;
} }
break; break;
@@ -85,7 +158,7 @@ class CayenneLppParser {
break; break;
case MeshCoreConstants.lppIlluminanceSensor: case MeshCoreConstants.lppIlluminanceSensor:
final value = reader.readUInt16BE(); final value = reader.readUInt16BE().toDouble();
debugPrint(' Illuminance: $value lux'); debugPrint(' Illuminance: $value lux');
extraSensorData['illuminance_$channel'] = value; extraSensorData['illuminance_$channel'] = value;
break; break;
@@ -98,18 +171,36 @@ class CayenneLppParser {
case MeshCoreConstants.lppTemperatureSensor: case MeshCoreConstants.lppTemperatureSensor:
final rawValue = reader.readInt16BE(); final rawValue = reader.readInt16BE();
temperature = rawValue / 10.0; final value = rawValue / 10.0;
debugPrint(' Temperature (raw): $rawValue'); debugPrint(' Temperature (raw): $rawValue');
debugPrint( debugPrint(' Temperature: ${value.toStringAsFixed(1)}°C');
' Temperature: ${temperature.toStringAsFixed(1)}°C', if (channel == _selfTelemetryChannel) {
); temperature = value;
extraSensorData[_sourceChannelKey('temperature')] = channel;
} else {
extraSensorData['temperature_$channel'] = value;
if (temperature == null) {
temperature = value;
extraSensorData[_sourceChannelKey('temperature')] = channel;
}
}
break; break;
case MeshCoreConstants.lppHumiditySensor: case MeshCoreConstants.lppHumiditySensor:
final rawValue = reader.readByte(); final rawValue = reader.readByte();
humidity = rawValue / 2.0; final value = rawValue / 2.0;
debugPrint(' Humidity (raw): $rawValue'); debugPrint(' Humidity (raw): $rawValue');
debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%'); debugPrint(' Humidity: ${value.toStringAsFixed(1)}%');
if (channel == _selfTelemetryChannel) {
humidity = value;
extraSensorData[_sourceChannelKey('humidity')] = channel;
} else {
extraSensorData['humidity_$channel'] = value;
if (humidity == null) {
humidity = value;
extraSensorData[_sourceChannelKey('humidity')] = channel;
}
}
break; break;
case MeshCoreConstants.lppAccelerometer: case MeshCoreConstants.lppAccelerometer:
@@ -126,9 +217,19 @@ class CayenneLppParser {
case MeshCoreConstants.lppBarometer: case MeshCoreConstants.lppBarometer:
final rawValue = reader.readUInt16BE(); final rawValue = reader.readUInt16BE();
pressure = rawValue / 10.0; final value = rawValue / 10.0;
debugPrint(' Barometer (raw): $rawValue'); debugPrint(' Barometer (raw): $rawValue');
debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa'); debugPrint(' Barometer: ${value.toStringAsFixed(1)} hPa');
if (channel == _selfTelemetryChannel) {
pressure = value;
extraSensorData[_sourceChannelKey('pressure')] = channel;
} else {
extraSensorData['pressure_$channel'] = value;
if (pressure == null) {
pressure = value;
extraSensorData[_sourceChannelKey('pressure')] = channel;
}
}
break; break;
case MeshCoreConstants.lppVoltageSensor: case MeshCoreConstants.lppVoltageSensor:
@@ -136,12 +237,17 @@ class CayenneLppParser {
final value = rawValue / 100.0; final value = rawValue / 100.0;
debugPrint(' Voltage (raw): $rawValue'); debugPrint(' Voltage (raw): $rawValue');
debugPrint(' Voltage: ${value}V'); debugPrint(' Voltage: ${value}V');
// Treat voltage sensor as battery reading if (_isBatteryChannel(channel)) {
batteryMilliVolts = value * 1000; batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value); batteryPercentage = _calculateBatteryPercentage(value);
extraSensorData[_sourceChannelKey('battery')] = channel;
extraSensorData[_sourceChannelKey('voltage')] = channel;
debugPrint( debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)', ' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
); );
} else {
extraSensorData['voltage_$channel'] = value;
}
break; break;
case MeshCoreConstants.lppGyrometer: case MeshCoreConstants.lppGyrometer:
@@ -194,17 +300,330 @@ class CayenneLppParser {
} }
gpsLocation = LatLng(lat, lon); gpsLocation = LatLng(lat, lon);
extraSensorData[_sourceChannelKey('gps')] = channel;
extraSensorData['altitude_$channel'] = alt; extraSensorData['altitude_$channel'] = alt;
break; break;
case _lppGenericSensor:
final value = _readUInt32BE(reader).toDouble();
debugPrint(' Generic Sensor: $value');
extraSensorData['generic_sensor_$channel'] = value;
break;
case _lppCurrent:
final rawValue = reader.readInt16BE();
final value = rawValue / 1000.0;
debugPrint(' Current (raw): $rawValue');
debugPrint(' Current: ${value}A');
extraSensorData['current_$channel'] = value;
break;
case _lppFrequency:
final value = _readUInt32BE(reader).toDouble();
debugPrint(' Frequency: ${value}Hz');
extraSensorData['frequency_$channel'] = value;
break;
case _lppPercentage:
final value = reader.readByte().toDouble();
debugPrint(' Percentage: $value%');
if (_isBatteryChannel(channel)) {
batteryPercentage = value;
extraSensorData[_sourceChannelKey('battery')] = channel;
} else {
extraSensorData['percentage_$channel'] = value;
}
break;
case _lppAltitude:
final rawValue = reader.readInt16BE();
final value = rawValue.toDouble();
debugPrint(' Altitude: ${value}m');
extraSensorData['altitude_$channel'] = value;
break;
case _lppConcentration:
final value = reader.readUInt16BE().toDouble();
debugPrint(' Concentration: ${value}ppm');
extraSensorData['concentration_$channel'] = value;
break;
case _lppPower:
final value = reader.readUInt16BE().toDouble();
debugPrint(' Power: ${value}W');
extraSensorData['power_$channel'] = value;
break;
case _lppSpeed:
final rawValue = reader.readUInt16BE();
final value = rawValue / 100.0;
debugPrint(' Speed: ${value}m/s');
extraSensorData['speed_$channel'] = value;
break;
case _lppDistance:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Distance: ${value}m');
extraSensorData['distance_$channel'] = value;
break;
case _lppEnergy:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Energy: ${value}kWh');
extraSensorData['energy_$channel'] = value;
break;
case _lppDirection:
final value = reader.readUInt16BE().toDouble();
debugPrint(' Direction: $value°');
extraSensorData['direction_$channel'] = value;
break;
case _lppUnixTime:
final value = _readUInt32BE(reader);
debugPrint(' Unix time: $value');
extraSensorData['unixtime_$channel'] = value;
break;
case _lppColour:
final red = reader.readByte();
final green = reader.readByte();
final blue = reader.readByte();
debugPrint(' Colour: r=$red, g=$green, b=$blue');
extraSensorData['colour_$channel'] = {
'r': red,
'g': green,
'b': blue,
};
break;
case _lppGust:
final rawValue = reader.readUInt16BE();
final value = rawValue / 100.0;
debugPrint(' Gust: ${value}m/s');
extraSensorData['gust_$channel'] = value;
break;
case _lppDewPoint:
final rawValue = reader.readInt16BE();
final value = rawValue / 10.0;
debugPrint(' Dew point: ${value.toStringAsFixed(1)}°C');
extraSensorData['dew_$channel'] = value;
break;
case _lppRain:
final rawValue = reader.readUInt16BE();
final value = rawValue / 10.0;
debugPrint(' Rain: ${value}mm');
extraSensorData['rain_$channel'] = value;
break;
case _lppBinaryBool:
case _lppBinaryPowerSwitch:
case _lppBinaryOpen:
case _lppBinaryBatteryLow:
case _lppBinaryCharging:
case _lppBinaryCarbonMonoxide:
case _lppBinaryCold:
case _lppBinaryConnectivity:
case _lppBinaryDoor:
case _lppBinaryGarageDoor:
case _lppBinaryGas:
case _lppBinaryHeat:
case _lppBinaryLight:
case _lppBinaryLock:
case _lppBinaryMoisture:
case _lppBinaryMotion:
case _lppBinaryMoving:
case _lppBinaryOccupancy:
case _lppBinaryPlug:
case _lppBinaryPresence:
case _lppBinaryProblem:
case _lppBinaryRunning:
case _lppBinarySafety:
case _lppBinarySmoke:
case _lppBinarySound:
case _lppBinaryTamper:
case _lppBinaryVibration:
case _lppBinaryWindow:
final value = reader.readByte();
debugPrint(' Binary state: $value');
extraSensorData['${_binaryMetricKeyForType(type)}_$channel'] =
value;
break;
case _lppButtonEvent:
final value = reader.readByte();
debugPrint(' Button event: $value');
extraSensorData['button_event_$channel'] = value;
break;
case _lppDimmer:
final value = _readInt8(reader);
debugPrint(' Dimmer: $value');
extraSensorData['dimmer_$channel'] = value;
break;
case _lppUv:
final value = reader.readByte() / 10.0;
debugPrint(' UV index: $value');
extraSensorData['uv_$channel'] = value;
break;
case _lppLightLevel:
final value = reader.readByte();
debugPrint(' Light level: $value');
extraSensorData['light_level_$channel'] = value;
break;
case _lppPm25:
final value = reader.readUInt16BE().toDouble();
debugPrint(' PM2.5: $value');
extraSensorData['pm25_$channel'] = value;
break;
case _lppPm10:
final value = reader.readUInt16BE().toDouble();
debugPrint(' PM10: $value');
extraSensorData['pm10_$channel'] = value;
break;
case _lppCo2:
final value = reader.readUInt16BE().toDouble();
debugPrint(' CO2: $value');
extraSensorData['co2_$channel'] = value;
break;
case _lppTvoc:
final value = reader.readUInt16BE().toDouble();
debugPrint(' TVOC: $value');
extraSensorData['tvoc_$channel'] = value;
break;
case _lppRpm:
final value = reader.readUInt16BE().toDouble();
debugPrint(' RPM: $value');
extraSensorData['rpm_$channel'] = value;
break;
case _lppConductivity:
final value = reader.readUInt16BE().toDouble();
debugPrint(' Conductivity: $value');
extraSensorData['conductivity_$channel'] = value;
break;
case _lppRotation:
final rawValue = reader.readInt16BE();
final value = rawValue / 10.0;
debugPrint(' Rotation: $value');
extraSensorData['rotation_$channel'] = value;
break;
case _lppDuration:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Duration: $value s');
extraSensorData['duration_$channel'] = value;
break;
case _lppAcceleration:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000000.0;
debugPrint(' Acceleration: $value');
extraSensorData['acceleration_$channel'] = value;
break;
case _lppGyroRate:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Gyro rate: $value');
extraSensorData['gyro_rate_$channel'] = value;
break;
case _lppVolume:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Volume: $value');
extraSensorData['volume_$channel'] = value;
break;
case _lppFlowRate:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Flow rate: $value');
extraSensorData['flow_rate_$channel'] = value;
break;
case _lppVolumeStorage:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Storage volume: $value');
extraSensorData['volume_storage_$channel'] = value;
break;
case _lppWater:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Water: $value');
extraSensorData['water_$channel'] = value;
break;
case _lppGasVolume:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Gas volume: $value');
extraSensorData['gas_volume_$channel'] = value;
break;
case _lppMass:
final rawValue = _readUInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Mass: $value');
extraSensorData['mass_$channel'] = value;
break;
case _lppSignedSpeed:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000000.0;
debugPrint(' Signed speed: $value');
extraSensorData['signed_speed_$channel'] = value;
break;
case _lppSignedPower:
final rawValue = _readInt32BE(reader);
final value = rawValue / 100.0;
debugPrint(' Signed power: $value');
extraSensorData['signed_power_$channel'] = value;
break;
case _lppSignedCurrent:
final rawValue = _readInt32BE(reader);
final value = rawValue / 1000.0;
debugPrint(' Signed current: $value');
extraSensorData['signed_current_$channel'] = value;
break;
case _lppSwitch:
final value = reader.readByte();
debugPrint(' Switch: $value');
extraSensorData['switch_$channel'] = value;
break;
default: default:
final size = _payloadSizeForType(type);
if (size == null || reader.remainingBytesCount < size) {
debugPrint( debugPrint(
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes', ' ⚠️ Unknown type $type with unsupported size, stopping parse',
); );
// Unknown type, skip remaining to avoid parsing errors
reader.skip(reader.remainingBytesCount); reader.skip(reader.remainingBytesCount);
break; break;
} }
debugPrint(' ⚠️ Unknown type $type, skipping $size bytes');
reader.skip(size);
break;
}
} catch (e) { } catch (e) {
debugPrint(' ❌ Parsing error: $e'); debugPrint(' ❌ Parsing error: $e');
// If we encounter a parsing error, break and return what we have // If we encounter a parsing error, break and return what we have
@@ -258,6 +677,163 @@ class CayenneLppParser {
return ((voltage - 3.0) / 1.2) * 100.0; return ((voltage - 3.0) / 1.2) * 100.0;
} }
static bool _isBatteryChannel(int channel) =>
channel == 0 || channel == _selfTelemetryChannel;
static int _readUInt32BE(BufferReader reader) {
final bytes = reader.readBytes(4);
return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
}
static int _readInt32BE(BufferReader reader) {
final value = _readUInt32BE(reader);
if ((value & 0x80000000) != 0) {
return value - 0x100000000;
}
return value;
}
static int _readInt8(BufferReader reader) {
final value = reader.readByte();
if ((value & 0x80) != 0) {
return value - 0x100;
}
return value;
}
static String _sourceChannelKey(String fieldKey) =>
'__source_channel:$fieldKey';
static String _binaryMetricKeyForType(int type) {
switch (type) {
case _lppBinaryBool:
return 'binary_bool';
case _lppBinaryPowerSwitch:
return 'binary_power_switch';
case _lppBinaryOpen:
return 'binary_open';
case _lppBinaryBatteryLow:
return 'binary_battery_low';
case _lppBinaryCharging:
return 'binary_charging';
case _lppBinaryCarbonMonoxide:
return 'binary_carbon_monoxide';
case _lppBinaryCold:
return 'binary_cold';
case _lppBinaryConnectivity:
return 'binary_connectivity';
case _lppBinaryDoor:
return 'binary_door';
case _lppBinaryGarageDoor:
return 'binary_garage_door';
case _lppBinaryGas:
return 'binary_gas';
case _lppBinaryHeat:
return 'binary_heat';
case _lppBinaryLight:
return 'binary_light';
case _lppBinaryLock:
return 'binary_lock';
case _lppBinaryMoisture:
return 'binary_moisture';
case _lppBinaryMotion:
return 'binary_motion';
case _lppBinaryMoving:
return 'binary_moving';
case _lppBinaryOccupancy:
return 'binary_occupancy';
case _lppBinaryPlug:
return 'binary_plug';
case _lppBinaryPresence:
return 'binary_presence';
case _lppBinaryProblem:
return 'binary_problem';
case _lppBinaryRunning:
return 'binary_running';
case _lppBinarySafety:
return 'binary_safety';
case _lppBinarySmoke:
return 'binary_smoke';
case _lppBinarySound:
return 'binary_sound';
case _lppBinaryTamper:
return 'binary_tamper';
case _lppBinaryVibration:
return 'binary_vibration';
case _lppBinaryWindow:
return 'binary_window';
}
return 'binary_state';
}
static int? _payloadSizeForType(int type) {
if (type >= _lppBinaryBool && type <= _lppBinaryWindow) {
return 1;
}
switch (type) {
case MeshCoreConstants.lppDigitalInput:
case MeshCoreConstants.lppDigitalOutput:
case MeshCoreConstants.lppPresenceSensor:
case MeshCoreConstants.lppHumiditySensor:
case _lppPercentage:
case _lppSwitch:
case _lppButtonEvent:
case _lppDimmer:
case _lppUv:
case _lppLightLevel:
return 1;
case MeshCoreConstants.lppAnalogInput:
case MeshCoreConstants.lppAnalogOutput:
case MeshCoreConstants.lppIlluminanceSensor:
case MeshCoreConstants.lppTemperatureSensor:
case MeshCoreConstants.lppBarometer:
case MeshCoreConstants.lppVoltageSensor:
case _lppCurrent:
case _lppAltitude:
case _lppConcentration:
case _lppPower:
case _lppSpeed:
case _lppDirection:
case _lppGust:
case _lppDewPoint:
case _lppRain:
case _lppPm25:
case _lppPm10:
case _lppCo2:
case _lppTvoc:
case _lppRpm:
case _lppConductivity:
case _lppRotation:
return 2;
case MeshCoreConstants.lppAccelerometer:
case MeshCoreConstants.lppGyrometer:
return 6;
case MeshCoreConstants.lppGps:
return 9;
case _lppGenericSensor:
case _lppFrequency:
case _lppDistance:
case _lppEnergy:
case _lppUnixTime:
case _lppDuration:
case _lppAcceleration:
case _lppGyroRate:
case _lppVolume:
case _lppFlowRate:
case _lppVolumeStorage:
case _lppWater:
case _lppGasVolume:
case _lppMass:
case _lppSignedSpeed:
case _lppSignedPower:
case _lppSignedCurrent:
return 4;
case _lppColour:
return 3;
}
return null;
}
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) { static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
final start = data.length - remainingBytes; final start = data.length - remainingBytes;
for (int i = start; i < data.length; i++) { for (int i = start; i < data.length; i++) {

View File

@@ -0,0 +1,336 @@
import 'dart:math' as math;
import 'package:latlong2/latlong.dart';
import '../models/contact.dart';
class ResolvedContactRoutePlan {
final List<String> tokens;
final List<Contact> selectedContacts;
final String summary;
const ResolvedContactRoutePlan({
required this.tokens,
required this.selectedContacts,
required this.summary,
});
String get canonicalText => tokens.join(',');
}
class ContactRouteResolver {
static const Distance _distance = Distance();
const ContactRouteResolver._();
static ResolvedContactRoutePlan? resolveAutomaticRoute({
required LatLng senderLocation,
required Contact recipient,
required List<Contact> availableContacts,
required int hashSize,
}) {
final recipientLocation = recipient.displayLocation;
if (recipientLocation == null) return null;
final repeaters = availableContacts
.where(
(contact) =>
contact.isRepeater &&
contact.displayLocation != null &&
contact.publicKeyHex != recipient.publicKeyHex,
)
.toList();
if (repeaters.isEmpty) return null;
final recipientLatLng = LatLng(
recipientLocation.latitude,
recipientLocation.longitude,
);
final routedCandidates =
repeaters
.where(
(contact) =>
contact.routeHasPath && contact.routeHashSize == hashSize,
)
.toList()
..sort(
(a, b) =>
_scoreKnownRouteRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: a,
availableContacts: repeaters,
hashSize: hashSize,
).compareTo(
_scoreKnownRouteRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: b,
availableContacts: repeaters,
hashSize: hashSize,
),
),
);
if (routedCandidates.isNotEmpty) {
final anchor = routedCandidates.first;
final tokens = <String>[
...anchor.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map((token) => token.toUpperCase()),
_tokenFor(anchor, hashSize),
];
final selectedContacts = _matchContactsForTokens(
tokens,
availableContacts: repeaters,
);
return ResolvedContactRoutePlan(
tokens: _dedupeTokens(tokens),
selectedContacts: selectedContacts,
summary: 'Resolved via known repeater route',
);
}
final corridorRepeaters = List<Contact>.from(repeaters)
..sort((a, b) {
final progressA = _progressAlongSegment(
point: LatLng(
a.displayLocation!.latitude,
a.displayLocation!.longitude,
),
start: senderLocation,
end: recipientLatLng,
);
final progressB = _progressAlongSegment(
point: LatLng(
b.displayLocation!.latitude,
b.displayLocation!.longitude,
),
start: senderLocation,
end: recipientLatLng,
);
final progressCompare = progressA.compareTo(progressB);
if (progressCompare != 0) return progressCompare;
return _scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: a,
).compareTo(
_scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLatLng,
repeater: b,
),
);
});
final selected = <Contact>[];
var currentPoint = senderLocation;
var currentDistanceToRecipient = _distance.as(
LengthUnit.Meter,
senderLocation,
recipientLatLng,
);
final maxCorridorDistance = math.max(
1500.0,
currentDistanceToRecipient * 0.22,
);
for (final repeater in corridorRepeaters) {
if (selected.length >= 4) break;
final repeaterPoint = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final distanceToSegment = _distanceToSegmentMeters(
repeaterPoint,
senderLocation,
recipientLatLng,
);
if (distanceToSegment > maxCorridorDistance) {
continue;
}
final nextDistanceToRecipient = _distance.as(
LengthUnit.Meter,
repeaterPoint,
recipientLatLng,
);
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
continue;
}
final distanceFromCurrent = _distance.as(
LengthUnit.Meter,
currentPoint,
repeaterPoint,
);
if (distanceFromCurrent < 100) {
continue;
}
selected.add(repeater);
currentPoint = repeaterPoint;
currentDistanceToRecipient = nextDistanceToRecipient;
if (currentDistanceToRecipient < 2500) {
break;
}
}
if (selected.isEmpty) {
return null;
}
return ResolvedContactRoutePlan(
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
selectedContacts: selected,
summary: 'Resolved from repeater locations',
);
}
static List<Contact> _matchContactsForTokens(
List<String> tokens, {
required List<Contact> availableContacts,
}) {
final matches = <Contact>[];
final seen = <String>{};
for (final token in tokens) {
final match = availableContacts
.where(
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
)
.firstOrNull;
if (match != null && seen.add(match.publicKeyHex)) {
matches.add(match);
}
}
return matches;
}
static List<String> _dedupeTokens(List<String> tokens) {
final result = <String>[];
for (final token in tokens) {
if (result.isEmpty || result.last != token) {
result.add(token);
}
}
return result;
}
static String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2;
return hex.length < length ? hex : hex.substring(0, length);
}
static double _scoreRepeater({
required LatLng senderLocation,
required LatLng recipientLocation,
required Contact repeater,
}) {
final point = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final toRecipient = _distance.as(
LengthUnit.Meter,
point,
recipientLocation,
);
final corridor = _distanceToSegmentMeters(
point,
senderLocation,
recipientLocation,
);
return (toRecipient * 0.75) + (corridor * 0.25);
}
static double _scoreKnownRouteRepeater({
required LatLng senderLocation,
required LatLng recipientLocation,
required Contact repeater,
required List<Contact> availableContacts,
required int hashSize,
}) {
final baseScore = _scoreRepeater(
senderLocation: senderLocation,
recipientLocation: recipientLocation,
repeater: repeater,
);
final repeaterPoint = LatLng(
repeater.displayLocation!.latitude,
repeater.displayLocation!.longitude,
);
final chainContacts = repeater.routeCanonicalText
.split(',')
.where((token) => token.isNotEmpty)
.map(
(token) => availableContacts
.where(
(contact) =>
contact.displayLocation != null &&
contact.publicKeyHex.toUpperCase().startsWith(
token.toUpperCase(),
),
)
.firstOrNull,
)
.whereType<Contact>()
.toList();
final chainEnd = chainContacts.isNotEmpty
? LatLng(
chainContacts.last.displayLocation!.latitude,
chainContacts.last.displayLocation!.longitude,
)
: senderLocation;
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
final senderGap = _distance.as(
LengthUnit.Meter,
senderLocation,
repeaterPoint,
);
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
}
static double _progressAlongSegment({
required LatLng point,
required LatLng start,
required LatLng end,
}) {
final dx = end.longitude - start.longitude;
final dy = end.latitude - start.latitude;
final lengthSquared = (dx * dx) + (dy * dy);
if (lengthSquared == 0) return 0;
return (((point.longitude - start.longitude) * dx) +
((point.latitude - start.latitude) * dy)) /
lengthSquared;
}
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
final ax = a.longitude;
final ay = a.latitude;
final bx = b.longitude;
final by = b.latitude;
final px = p.longitude;
final py = p.latitude;
final abx = bx - ax;
final aby = by - ay;
final apx = px - ax;
final apy = py - ay;
final ab2 = abx * abx + aby * aby;
if (ab2 == 0) {
return _distance.as(LengthUnit.Meter, a, p);
}
var t = (apx * abx + apy * aby) / ab2;
t = t.clamp(0.0, 1.0);
final closest = LatLng(ay + aby * t, ax + abx * t);
return _distance.as(LengthUnit.Meter, closest, p);
}
}

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,8 @@ class MapMarkerService {
double mapRotation = 0, double mapRotation = 0,
Position? userPosition, Position? userPosition,
}) { }) {
return contacts.map((contact) { return contacts
.map((contact) {
final location = contact.displayLocation; final location = contact.displayLocation;
if (location == null) return null; if (location == null) return null;
@@ -60,7 +61,10 @@ class MapMarkerService {
children: [ children: [
// Location update time indicator // Location update time indicator
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: getLocationAgeColor(contact), color: getLocationAgeColor(contact),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
@@ -79,11 +83,13 @@ class MapMarkerService {
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
shape: contact.type == ContactType.channel || shape:
contact.type == ContactType.channel ||
contact.type == ContactType.room contact.type == ContactType.room
? BoxShape.rectangle ? BoxShape.rectangle
: BoxShape.circle, : BoxShape.circle,
borderRadius: contact.type == ContactType.channel || borderRadius:
contact.type == ContactType.channel ||
contact.type == ContactType.room contact.type == ContactType.room
? BorderRadius.circular(14) ? BorderRadius.circular(14)
: null, : null,
@@ -102,7 +108,10 @@ class MapMarkerService {
// Name label (without emoji) // Name label (without emoji)
Container( Container(
constraints: const BoxConstraints(maxWidth: 80), constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7), color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
@@ -124,7 +133,9 @@ class MapMarkerService {
), ),
), ),
); );
}).whereType<Marker>().toList(); })
.whereType<Marker>()
.toList();
} }
/// Generate markers for SAR events. /// Generate markers for SAR events.
@@ -157,7 +168,10 @@ class MapMarkerService {
children: [ children: [
// Time ago label // Time ago label
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: getSarMarkerColor(marker.type), color: getSarMarkerColor(marker.type),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
@@ -196,13 +210,17 @@ class MapMarkerService {
// Type label // Type label
Container( Container(
constraints: const BoxConstraints(maxWidth: 90), constraints: const BoxConstraints(maxWidth: 90),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7), color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
), ),
child: Text( child: Text(
marker.displayName, // Uses notes if available, otherwise type.displayName marker
.displayName, // Uses notes if available, otherwise type.displayName
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 9, fontSize: 9,
@@ -269,7 +287,8 @@ class MapMarkerService {
final dLat = (lat2 - lat1) * pi / 180; final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180;
final a = sin(dLat / 2) * sin(dLat / 2) + final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) * cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) * cos(lat2 * pi / 180) *
sin(dLon / 2) * sin(dLon / 2) *
@@ -299,8 +318,8 @@ class MapMarkerService {
final lat2Rad = lat2 * pi / 180; final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad); final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) - final x =
sin(lat1Rad) * cos(lat2Rad) * cos(dLon); cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi; final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360; return (bearing + 360) % 360;
@@ -368,6 +387,8 @@ class MapMarkerService {
return Colors.deepPurple; // Purple for repeaters return Colors.deepPurple; // Purple for repeaters
case ContactType.room: case ContactType.room:
return Colors.teal; // Teal for rooms return Colors.teal; // Teal for rooms
case ContactType.sensor:
return Colors.green; // Green for sensors
case ContactType.channel: case ContactType.channel:
return Colors.orange; // Orange for channels return Colors.orange; // Orange for channels
case ContactType.none: case ContactType.none:
@@ -389,6 +410,8 @@ class MapMarkerService {
return Icons.router; // Router icon for repeaters return Icons.router; // Router icon for repeaters
case ContactType.room: case ContactType.room:
return Icons.forum; // Forum/chat icon for rooms return Icons.forum; // Forum/chat icon for rooms
case ContactType.sensor:
return Icons.sensors; // Sensors icon for sensor nodes
case ContactType.channel: case ContactType.channel:
return Icons.public; // Public icon for channels return Icons.public; // Public icon for channels
case ContactType.none: case ContactType.none:
@@ -466,7 +489,8 @@ class MapMarkerService {
} }
if (allPoints.isEmpty) { if (allPoints.isEmpty) {
return defaultCenter ?? const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia return defaultCenter ??
const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
} }
double lat = 0, lng = 0; double lat = 0, lng = 0;

View File

@@ -35,6 +35,7 @@ class MeshMapNode {
class MeshMapNodesService { class MeshMapNodesService {
static const String _nodesEndpoint = static const String _nodesEndpoint =
'https://api.meshcore.nz/api/v1/map/nodes'; 'https://api.meshcore.nz/api/v1/map/nodes';
static const int repeaterType = 1;
static const Duration _cacheTtl = Duration(hours: 24); static const Duration _cacheTtl = Duration(hours: 24);
static const Duration traceCacheTtl = _cacheTtl; static const Duration traceCacheTtl = _cacheTtl;
static const Duration traceTimeout = Duration(seconds: 30); static const Duration traceTimeout = Duration(seconds: 30);
@@ -182,6 +183,8 @@ class MeshMapNodesService {
return _cachedAt; return _cachedAt;
} }
static bool isRepeater(MeshMapNode node) => node.type == repeaterType;
static Future<void> _storeCache( static Future<void> _storeCache(
List<MeshMapNode> nodes, { List<MeshMapNode> nodes, {
required DateTime cachedAt, required DateTime cachedAt,

View File

@@ -11,6 +11,7 @@ import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage /// Service for persisting messages to local storage
class MessageStorageService { class MessageStorageService {
static const String _messagesKey = 'stored_messages'; static const String _messagesKey = 'stored_messages';
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
static const String _messageContactLocationsKey = static const String _messageContactLocationsKey =
'stored_message_contact_locations'; 'stored_message_contact_locations';
static const String _messageReceptionDetailsKey = static const String _messageReceptionDetailsKey =
@@ -19,6 +20,8 @@ class MessageStorageService {
'stored_message_transfer_details'; 'stored_message_transfer_details';
static const String _messageRouteMetadataKey = static const String _messageRouteMetadataKey =
'stored_message_route_metadata'; 'stored_message_route_metadata';
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
static const String _legacyPathBytesKey = 'storedPathBytes';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage /// Save messages to persistent storage
@@ -32,8 +35,16 @@ class MessageStorageService {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
// Convert messages to JSON // Convert messages to JSON and embed path bytes as a fallback so they
final jsonList = messages.map((msg) => _messageToJson(msg)).toList(); // survive restore even if the sidecar reception-details entry is absent.
final jsonList = messages
.map(
(msg) => _messageToJson(
msg,
receptionDetails: messageReceptionDetails[msg.id],
),
)
.toList();
// Limit to max stored messages (keep most recent) // Limit to max stored messages (keep most recent)
final limitedList = jsonList.length > _maxStoredMessages final limitedList = jsonList.length > _maxStoredMessages
@@ -129,16 +140,10 @@ class MessageStorageService {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey); final jsonString = prefs.getString(_messageReceptionDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageReceptionDetails>{}; final result = <String, MessageReceptionDetails>{};
if (jsonString != null && jsonString.isNotEmpty) {
final decoded = jsonDecode(jsonString);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) { for (final entry in decoded.entries) {
final value = entry.value; final value = entry.value;
if (value is! Map<String, dynamic>) continue; if (value is! Map<String, dynamic>) continue;
@@ -147,6 +152,24 @@ class MessageStorageService {
result[entry.key] = snapshot; result[entry.key] = snapshot;
} }
} }
}
}
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
embeddedReceptionDetails.forEach((messageId, snapshot) {
result.putIfAbsent(messageId, () => snapshot);
});
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
fallbackPathBytes.forEach((messageId, pathBytes) {
result.putIfAbsent(
messageId,
() => MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
pathBytes: pathBytes,
),
);
});
return result; return result;
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e'); debugPrint('❌ [MessageStorage] Error loading reception details: $e');
@@ -247,12 +270,36 @@ class MessageStorageService {
await prefs.remove(_messageReceptionDetailsKey); await prefs.remove(_messageReceptionDetailsKey);
await prefs.remove(_messageTransferDetailsKey); await prefs.remove(_messageTransferDetailsKey);
await prefs.remove(_messageRouteMetadataKey); await prefs.remove(_messageRouteMetadataKey);
await prefs.remove(_removedSarMarkerIdsKey);
debugPrint('✅ [MessageStorage] Cleared all stored messages'); debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e'); debugPrint('❌ [MessageStorage] Error clearing messages: $e');
} }
} }
Future<Set<String>> loadRemovedSarMarkerIds() async {
try {
final prefs = await SharedPreferences.getInstance();
final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const [];
return ids.toSet();
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
return const <String>{};
}
}
Future<void> saveRemovedSarMarkerIds(Set<String> ids) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort());
debugPrint(
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
);
} catch (e) {
debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e');
}
}
/// Get storage statistics /// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async { Future<Map<String, dynamic>> getStorageStats() async {
try { try {
@@ -278,7 +325,12 @@ class MessageStorageService {
} }
/// Convert Message to JSON /// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) { Map<String, dynamic> _messageToJson(
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
final persistedPathBytes =
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
return { return {
'id': message.id, 'id': message.id,
'messageType': message.messageType.name, 'messageType': message.messageType.name,
@@ -287,6 +339,7 @@ class MessageStorageService {
: null, : null,
'channelIdx': message.channelIdx, 'channelIdx': message.channelIdx,
'pathLen': message.pathLen, 'pathLen': message.pathLen,
'pathBytes': persistedPathBytes?.toList(),
'textType': message.textType.value, 'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp, 'senderTimestamp': message.senderTimestamp,
'text': message.text, 'text': message.text,
@@ -338,9 +391,67 @@ class MessageStorageService {
}, },
) )
.toList(), .toList(),
if (receptionDetails != null)
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
if (receptionDetails?.pathBytes case final pathBytes?)
_legacyPathBytesKey: List<int>.from(pathBytes),
}; };
} }
Future<Map<String, MessageReceptionDetails>>
_loadEmbeddedReceptionDetails() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final embedded = entry[_embeddedReceptionDetailsKey];
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(embedded);
if (snapshot == null) continue;
result[messageId] = snapshot;
}
return result;
}
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, List<int>>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final pathBytes = entry[_legacyPathBytesKey];
if (messageId is! String || pathBytes is! List) continue;
final normalized = pathBytes
.whereType<num>()
.map((b) => b.toInt())
.toList();
if (normalized.isEmpty) continue;
result[messageId] = normalized;
}
return result;
}
/// Convert JSON to Message /// Convert JSON to Message
Message? _messageFromJson(Map<String, dynamic> json) { Message? _messageFromJson(Map<String, dynamic> json) {
try { try {
@@ -357,6 +468,14 @@ class MessageStorageService {
: null, : null,
channelIdx: json['channelIdx'] as int?, channelIdx: json['channelIdx'] as int?,
pathLen: json['pathLen'] as int, pathLen: json['pathLen'] as int,
pathBytes: json['pathBytes'] is List
? Uint8List.fromList(
(json['pathBytes'] as List<dynamic>)
.whereType<num>()
.map((b) => b.toInt())
.toList(),
)
: null,
textType: MessageTextType.fromValue(json['textType'] as int), textType: MessageTextType.fromValue(json['textType'] as int),
senderTimestamp: json['senderTimestamp'] as int, senderTimestamp: json['senderTimestamp'] as int,
text: json['text'] as String, text: json['text'] as String,
@@ -454,4 +573,12 @@ class MessageStorageService {
return null; return null;
} }
} }
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
final pathBytes = receptionDetails?.pathBytes;
if (pathBytes == null || pathBytes.isEmpty) {
return null;
}
return Uint8List.fromList(pathBytes);
}
} }

View File

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

View File

@@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/path_history.dart'; import '../models/path_history.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../utils/log_rx_route_decoder.dart';
class PathHistoryService { class PathHistoryService {
static const String _storageKey = 'contact_path_history_v1'; static const String _storageKey = 'contact_path_history_v1';
@@ -53,6 +54,7 @@ class PathHistoryService {
pathBytes: contact.routePathBytes.toList(), pathBytes: contact.routePathBytes.toList(),
hopCount: contact.routeHopCount, hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize, hashSize: contact.routeHashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: existing?.successCount ?? 0, successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0, failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
@@ -83,15 +85,21 @@ class PathHistoryService {
return; return;
} }
final normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes(
pathBytes,
hashSize: hashSize,
);
final history = _historyFor(contactPublicKeyHex); final history = _historyFor(contactPublicKeyHex);
final signature = pathBytes final signature = normalizedPathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0')) .map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join(); .join();
final existing = _findDirectPath(history.directPaths, signature); final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord( final updated = PathRecord(
pathBytes: List<int>.from(pathBytes), pathBytes: normalizedPathBytes,
hopCount: pathBytes.length ~/ hashSize, hopCount: normalizedPathBytes.length ~/ hashSize,
hashSize: hashSize, hashSize: hashSize,
source: PathRecordSource.observed,
successCount: existing?.successCount ?? 0, successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0, failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
@@ -191,6 +199,7 @@ class PathHistoryService {
pathBytes: selection.pathBytes.toList(), pathBytes: selection.pathBytes.toList(),
hopCount: selection.hopCount, hopCount: selection.hopCount,
hashSize: selection.hashSize, hashSize: selection.hashSize,
source: existing?.source ?? PathRecordSource.learned,
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0), successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1), failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
lastRoundTripTimeMs: success lastRoundTripTimeMs: success

View File

@@ -1,785 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:shelf/shelf.dart' as shelf;
import 'package:shelf/shelf_io.dart' as io;
import 'package:nsd/nsd.dart';
import '../models/message.dart';
import '../models/contact.dart';
import '../models/sse_server_config.dart';
import 'network_scanner_service.dart';
/// SSE Server Service
///
/// Provides a web server with SSE (Server-Sent Events) endpoints for
/// real-time message and contact updates, enabling multiple app instances
/// to share a single MeshCore BLE device.
///
/// Endpoints:
/// - GET /sse/messages - SSE stream for message updates
/// - GET /sse/contacts - SSE stream for contact updates
/// - POST /api/messages - Send message
/// - POST /api/messages/channel - Send channel message
/// - POST /api/contacts/sync - Trigger contact sync
/// - GET /api/messages/history - Get all messages
/// - GET /api/contacts - Get all contacts
/// - GET /api/status - Server health check
class SseServerService {
HttpServer? _server;
SseServerConfig? _config;
Registration? _bonjourRegistration;
/// Active SSE connections for messages
final Set<StreamController<String>> _messageStreams = {};
/// Active SSE connections for contacts
final Set<StreamController<String>> _contactStreams = {};
/// Message history (for new clients)
final List<Message> _messageHistory = [];
/// Contact list (for new clients)
final Map<String, Contact> _contacts = {};
/// Timer for cleaning up dead connections
Timer? _cleanupTimer;
/// Device name (for status endpoint)
String? _deviceName;
/// Set device name
void setDeviceName(String? name) {
_deviceName = name;
debugPrint('📝 [SseServer] Device name set to: $name');
}
/// Callback for when a client requests to send a message
Future<bool> Function(String recipientPublicKey, String text)? onSendMessage;
/// Callback for when a client requests to send a channel message
Future<void> Function(int channelIdx, String text)? onSendChannelMessage;
/// Callback for when a client requests contact sync
Future<void> Function()? onSyncContacts;
/// Check if server is running
bool get isRunning => _server != null;
/// Get current configuration
SseServerConfig? get config => _config;
/// Get number of connected clients
int get connectedClients => _messageStreams.length;
/// CORS middleware
static shelf.Middleware get _corsHeaders {
return shelf.createMiddleware(
responseHandler: (shelf.Response response) {
return response.change(
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers':
'Origin, Content-Type, Authorization',
},
);
},
);
}
/// Start the SSE server
Future<void> startServer(SseServerConfig config) async {
if (_server != null) {
debugPrint('⚠️ [SseServer] Server already running');
return;
}
_config = config;
try {
debugPrint(
'🚀 [SseServer] Starting server on ${config.host}:${config.port}',
);
// Create shelf handler with CORS support
final handler = const shelf.Pipeline()
.addMiddleware(_corsHeaders)
.addMiddleware(shelf.logRequests())
.addHandler(_handleRequest);
// Start HTTP server
_server = await io.serve(handler, config.host, config.port);
debugPrint('✅ [SseServer] Server started on ${config.getServerUrl()}');
// Start cleanup timer for dead connections
_startCleanupTimer();
// Register Bonjour/mDNS service
await _registerBonjourService(config);
} catch (e) {
debugPrint('❌ [SseServer] Failed to start server: $e');
_server = null;
rethrow;
}
}
/// Register Bonjour/mDNS service for network discovery
Future<void> _registerBonjourService(SseServerConfig config) async {
try {
debugPrint(
'📡 [SseServer] Registering Bonjour service ${NetworkScannerService.serviceType}...',
);
_bonjourRegistration = await register(
const Service(
name: 'MeshCore SSE Server',
type: NetworkScannerService.serviceType,
port: 0, // Will be set dynamically
),
);
// Update with actual port
if (_bonjourRegistration != null) {
// Unregister and re-register with correct port
await unregister(_bonjourRegistration!);
_bonjourRegistration = await register(
Service(
name: 'MeshCore SSE Server',
type: NetworkScannerService.serviceType,
port: config.port,
),
);
debugPrint(
'✅ [SseServer] Bonjour service registered on port ${config.port}',
);
}
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to register Bonjour service: $e');
// Don't throw - server can still work without Bonjour
}
}
/// Start cleanup timer to remove dead connections
void _startCleanupTimer() {
_cleanupTimer?.cancel();
_cleanupTimer = Timer.periodic(const Duration(seconds: 60), (timer) {
_cleanupDeadConnections();
});
debugPrint('🧹 [SseServer] Cleanup timer started (60s interval)');
}
/// Clean up dead/closed connections
void _cleanupDeadConnections() {
// Clean up message streams
final deadMessageStreams = _messageStreams
.where((s) => s.isClosed)
.toList();
for (final stream in deadMessageStreams) {
_messageStreams.remove(stream);
}
// Clean up contact streams
final deadContactStreams = _contactStreams
.where((s) => s.isClosed)
.toList();
for (final stream in deadContactStreams) {
_contactStreams.remove(stream);
}
if (deadMessageStreams.isNotEmpty || deadContactStreams.isNotEmpty) {
debugPrint(
'🧹 [SseServer] Cleaned up ${deadMessageStreams.length} dead message streams, ${deadContactStreams.length} dead contact streams',
);
debugPrint(
' Active: ${_messageStreams.length} message clients, ${_contactStreams.length} contact clients',
);
}
}
/// Stop the SSE server
Future<void> stopServer() async {
if (_server == null) {
return;
}
debugPrint('🛑 [SseServer] Stopping server...');
// Stop cleanup timer
_cleanupTimer?.cancel();
_cleanupTimer = null;
// Close all SSE streams
for (final stream in _messageStreams) {
await stream.close();
}
_messageStreams.clear();
for (final stream in _contactStreams) {
await stream.close();
}
_contactStreams.clear();
// Unregister Bonjour service
if (_bonjourRegistration != null) {
try {
await unregister(_bonjourRegistration!);
debugPrint('✅ [SseServer] Bonjour service unregistered');
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to unregister Bonjour service: $e');
}
_bonjourRegistration = null;
}
// Close HTTP server
await _server!.close(force: true);
_server = null;
_config = null;
debugPrint('✅ [SseServer] Server stopped');
}
/// Main request handler
Future<shelf.Response> _handleRequest(shelf.Request request) async {
// Check authentication if token is configured
if (_config?.authToken != null) {
final authHeader = request.headers['authorization'];
if (authHeader != 'Bearer ${_config!.authToken}') {
return shelf.Response.forbidden('Invalid authentication token');
}
}
final path = request.url.path;
final method = request.method;
debugPrint('📨 [SseServer] $method /$path');
// Route requests
if (method == 'GET' && path == 'sse/messages') {
return _handleSseMessages(request);
} else if (method == 'GET' && path == 'sse/contacts') {
return _handleSseContacts(request);
} else if (method == 'POST' && path == 'api/messages') {
return _handlePostMessage(request);
} else if (method == 'POST' && path == 'api/messages/channel') {
return _handlePostChannelMessage(request);
} else if (method == 'POST' && path == 'api/contacts/sync') {
return _handlePostContactsSync(request);
} else if (method == 'GET' && path == 'api/messages/history') {
return _handleGetMessageHistory(request);
} else if (method == 'GET' && path == 'api/contacts') {
return _handleGetContacts(request);
} else if (method == 'GET' && path == 'api/status') {
return _handleGetStatus(request);
} else if (method == 'GET' && path == '') {
return _handleRoot(request);
}
return shelf.Response.notFound('Not found');
}
/// Handle SSE messages stream
shelf.Response _handleSseMessages(shelf.Request request) {
return request.hijack((channel) async {
debugPrint(
'📥 [SseServer] New SSE client connected (messages) via hijack',
);
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
// Send SSE headers
sink.add('HTTP/1.1 200 OK\r\n');
sink.add('Content-Type: text/event-stream\r\n');
sink.add('Cache-Control: no-cache\r\n');
sink.add('Connection: keep-alive\r\n');
sink.add('\r\n');
// Create controller for this connection
final controller = StreamController<String>();
_messageStreams.add(controller);
debugPrint(' Total clients: ${_messageStreams.length}');
// Send initial connection event
sink.add(': connected\n\n');
// Send initial message history
for (final message in _messageHistory) {
final event = _formatSseEvent('message', _messageToJson(message));
sink.add(event);
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
timer,
) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
timer.cancel();
}
});
// Listen to controller for new messages to broadcast
final subscription = controller.stream.listen(
(data) {
try {
sink.add(data);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send data: $e');
}
},
onDone: () {
debugPrint('📤 [SseServer] Controller stream closed');
},
);
// Wait for channel to close
await channel.stream.drain();
// Cleanup
keepAliveTimer.cancel();
await subscription.cancel();
_messageStreams.remove(controller);
await controller.close();
debugPrint('📤 [SseServer] SSE client disconnected (messages)');
debugPrint(' Total clients: ${_messageStreams.length}');
});
}
/// Handle SSE contacts stream
shelf.Response _handleSseContacts(shelf.Request request) {
return request.hijack((channel) async {
debugPrint(
'📥 [SseServer] New SSE client connected (contacts) via hijack',
);
// Set up the sink for sending data
final sink = utf8.encoder.startChunkedConversion(channel.sink);
// Send SSE headers
sink.add('HTTP/1.1 200 OK\r\n');
sink.add('Content-Type: text/event-stream\r\n');
sink.add('Cache-Control: no-cache\r\n');
sink.add('Connection: keep-alive\r\n');
sink.add('\r\n');
// Create controller for this connection
final controller = StreamController<String>();
_contactStreams.add(controller);
debugPrint(' Total clients: ${_contactStreams.length}');
// Send initial connection event
sink.add(': connected\n\n');
// Send initial contact list
for (final contact in _contacts.values) {
final event = _formatSseEvent('contact', _contactToJson(contact));
sink.add(event);
}
// Start keep-alive timer
final keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (
timer,
) {
try {
sink.add(': keepalive\n\n');
} catch (e) {
debugPrint('⚠️ [SseServer] Keep-alive failed: $e');
timer.cancel();
}
});
// Listen to controller for new messages to broadcast
final subscription = controller.stream.listen(
(data) {
try {
sink.add(data);
} catch (e) {
debugPrint('⚠️ [SseServer] Failed to send data: $e');
}
},
onDone: () {
debugPrint('📤 [SseServer] Controller stream closed');
},
);
// Wait for channel to close
await channel.stream.drain();
// Cleanup
keepAliveTimer.cancel();
await subscription.cancel();
_contactStreams.remove(controller);
await controller.close();
debugPrint('📤 [SseServer] SSE client disconnected (contacts)');
debugPrint(' Total clients: ${_contactStreams.length}');
});
}
/// Handle POST message request
Future<shelf.Response> _handlePostMessage(shelf.Request request) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
final recipientPublicKey = json['recipientPublicKey'] as String;
final text = json['text'] as String;
if (onSendMessage == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Send message callback not configured'}),
);
}
final success = await onSendMessage!(recipientPublicKey, text);
return shelf.Response.ok(
jsonEncode({'success': success}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST message: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle POST channel message request
Future<shelf.Response> _handlePostChannelMessage(
shelf.Request request,
) async {
try {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
final channelIdx = json['channelIdx'] as int;
final text = json['text'] as String;
if (onSendChannelMessage == null) {
return shelf.Response.internalServerError(
body: jsonEncode({
'error': 'Send channel message callback not configured',
}),
);
}
await onSendChannelMessage!(channelIdx, text);
return shelf.Response.ok(
jsonEncode({'success': true}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST channel message: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle POST contacts sync request
Future<shelf.Response> _handlePostContactsSync(shelf.Request request) async {
try {
if (onSyncContacts == null) {
return shelf.Response.internalServerError(
body: jsonEncode({'error': 'Sync contacts callback not configured'}),
);
}
await onSyncContacts!();
return shelf.Response.ok(
jsonEncode({'success': true}),
headers: {'content-type': 'application/json'},
);
} catch (e) {
debugPrint('❌ [SseServer] Error handling POST contacts sync: $e');
return shelf.Response.internalServerError(
body: jsonEncode({'error': e.toString()}),
);
}
}
/// Handle GET message history request
shelf.Response _handleGetMessageHistory(shelf.Request request) {
final messages = _messageHistory.map(_messageToJson).toList();
return shelf.Response.ok(
jsonEncode({'messages': messages}),
headers: {'content-type': 'application/json'},
);
}
/// Handle GET contacts request
shelf.Response _handleGetContacts(shelf.Request request) {
final contacts = _contacts.values.map(_contactToJson).toList();
return shelf.Response.ok(
jsonEncode({'contacts': contacts}),
headers: {'content-type': 'application/json'},
);
}
/// Handle GET status request
shelf.Response _handleGetStatus(shelf.Request request) {
return shelf.Response.ok(
jsonEncode({
'status': 'running',
'connectedClients': connectedClients,
'messageCount': _messageHistory.length,
'contactCount': _contacts.length,
'deviceName': _deviceName,
}),
headers: {'content-type': 'application/json'},
);
}
/// Handle root request (landing page)
shelf.Response _handleRoot(shelf.Request request) {
final html = '''
<!DOCTYPE html>
<html>
<head>
<title>MeshCore SAR - SSE Server</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; margin: 40px; background: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
h1 { color: #333; }
.status { background: #4CAF50; color: white; padding: 10px; border-radius: 4px; margin: 20px 0; }
.endpoint { background: #f9f9f9; padding: 10px; margin: 10px 0; border-left: 3px solid #2196F3; font-family: monospace; }
code { background: #eee; padding: 2px 6px; border-radius: 3px; }
</style>
</head>
<body>
<div class="container">
<h1>🚀 MeshCore SAR Server</h1>
<div class="status">✅ Server is running</div>
<p>This server enables multiple MeshCore SAR clients to share a single BLE device.</p>
<h2>📡 SSE Endpoints</h2>
<div class="endpoint">GET /sse/messages</div>
<div class="endpoint">GET /sse/contacts</div>
<h2>🔧 API Endpoints</h2>
<div class="endpoint">POST /api/messages</div>
<div class="endpoint">POST /api/messages/channel</div>
<div class="endpoint">POST /api/contacts/sync</div>
<div class="endpoint">GET /api/messages/history</div>
<div class="endpoint">GET /api/contacts</div>
<div class="endpoint">GET /api/status</div>
<h2>📊 Stats</h2>
<p>Connected clients: <strong id="clients">Loading...</strong></p>
<p>Messages: <strong id="messages">Loading...</strong></p>
<p>Contacts: <strong id="contacts">Loading...</strong></p>
</div>
<script>
async function updateStats() {
try {
const res = await fetch('/api/status');
const data = await res.json();
document.getElementById('clients').textContent = data.connectedClients;
document.getElementById('messages').textContent = data.messageCount;
document.getElementById('contacts').textContent = data.contactCount;
} catch (e) {
console.error('Failed to fetch stats:', e);
}
}
updateStats();
setInterval(updateStats, 5000);
</script>
</body>
</html>
''';
return shelf.Response.ok(html, headers: {'content-type': 'text/html'});
}
/// Broadcast a new message to all SSE clients
void broadcastMessage(Message message) {
// Add to history (limit to 1000 messages)
_messageHistory.add(message);
if (_messageHistory.length > 1000) {
_messageHistory.removeAt(0);
}
// Broadcast to all connected clients
final event = _formatSseEvent('message', _messageToJson(message));
final deadStreams = <StreamController<String>>[];
for (final stream in _messageStreams) {
if (stream.isClosed) {
deadStreams.add(stream);
} else {
try {
stream.add(event);
} catch (e) {
debugPrint(
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
);
deadStreams.add(stream);
}
}
}
// Remove dead streams
for (final stream in deadStreams) {
_messageStreams.remove(stream);
stream.close().catchError(
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
);
}
if (deadStreams.isNotEmpty) {
debugPrint(
'🧹 [SseServer] Removed ${deadStreams.length} dead message streams during broadcast',
);
}
debugPrint(
'📢 [SseServer] Broadcasted message to ${_messageStreams.length} clients',
);
}
/// Broadcast a new or updated contact to all SSE clients
void broadcastContact(Contact contact) {
// Update contact list
_contacts[contact.publicKeyHex] = contact;
// Broadcast to all connected clients
final event = _formatSseEvent('contact', _contactToJson(contact));
final deadStreams = <StreamController<String>>[];
for (final stream in _contactStreams) {
if (stream.isClosed) {
deadStreams.add(stream);
} else {
try {
stream.add(event);
} catch (e) {
debugPrint(
'⚠️ [SseServer] Failed to send to stream, marking as dead: $e',
);
deadStreams.add(stream);
}
}
}
// Remove dead streams
for (final stream in deadStreams) {
_contactStreams.remove(stream);
stream.close().catchError(
(e) => debugPrint('⚠️ [SseServer] Error closing dead stream: $e'),
);
}
if (deadStreams.isNotEmpty) {
debugPrint(
'🧹 [SseServer] Removed ${deadStreams.length} dead contact streams during broadcast',
);
}
debugPrint(
'📢 [SseServer] Broadcasted contact to ${_contactStreams.length} clients',
);
}
/// Format SSE event
String _formatSseEvent(String eventType, Map<String, dynamic> data) {
final jsonData = jsonEncode(data);
return 'event: $eventType\ndata: $jsonData\n\n';
}
/// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) {
return {
'id': message.id,
'messageType': message.messageType.name,
'senderPublicKeyPrefix': message.senderPublicKeyPrefix?.toList(),
'channelIdx': message.channelIdx,
'pathLen': message.pathLen,
'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp,
'text': message.text,
'isSarMarker': message.isSarMarker,
'sarGpsCoordinates': message.sarGpsCoordinates != null
? {
'latitude': message.sarGpsCoordinates!.latitude,
'longitude': message.sarGpsCoordinates!.longitude,
}
: null,
'sarNotes': message.sarNotes,
'sarCustomEmoji': message.sarCustomEmoji,
'sarColorIndex': message.sarColorIndex,
'receivedAt': message.receivedAt.toIso8601String(),
'senderName': message.senderName,
'deliveryStatus': message.deliveryStatus.name,
'expectedAckTag': message.expectedAckTag,
'suggestedTimeoutMs': message.suggestedTimeoutMs,
'roundTripTimeMs': message.roundTripTimeMs,
'deliveredAt': message.deliveredAt?.toIso8601String(),
'recipientPublicKey': message.recipientPublicKey?.toList(),
'retryAttempt': message.retryAttempt,
'lastRetryAt': message.lastRetryAt?.toIso8601String(),
'usedFloodFallback': message.usedFloodFallback,
'isRead': message.isRead,
'echoCount': message.echoCount,
'firstEchoAt': message.firstEchoAt?.toIso8601String(),
'lastEchoSnrRaw': message.lastEchoSnrRaw,
'lastEchoRssiDbm': message.lastEchoRssiDbm,
'lastEchoAt': message.lastEchoAt?.toIso8601String(),
'isDrawing': message.isDrawing,
'drawingId': message.drawingId,
};
}
/// Convert Contact to JSON
Map<String, dynamic> _contactToJson(Contact contact) {
return {
'publicKey': contact.publicKey.toList(),
'publicKeyHex': contact.publicKeyHex,
'type': contact.type.value,
'flags': contact.flags,
'outPathLen': contact.outPathLen,
'outPath': contact.outPath.toList(),
'advName': contact.advName,
'lastAdvert': contact.lastAdvert,
'advLat': contact.advLat,
'advLon': contact.advLon,
'lastMod': contact.lastMod,
'telemetry': contact.telemetry != null
? {
'batteryPercentage': contact.telemetry!.batteryPercentage,
'batteryMilliVolts': contact.telemetry!.batteryMilliVolts,
'temperature': contact.telemetry!.temperature,
'humidity': contact.telemetry!.humidity,
'pressure': contact.telemetry!.pressure,
'gpsLocation': contact.telemetry!.gpsLocation != null
? {
'latitude': contact.telemetry!.gpsLocation!.latitude,
'longitude': contact.telemetry!.gpsLocation!.longitude,
}
: null,
'timestamp': contact.telemetry!.timestamp.toIso8601String(),
}
: null,
};
}
/// Clear message history
void clearMessageHistory() {
_messageHistory.clear();
}
/// Clear contact list
void clearContacts() {
_contacts.clear();
}
}

View File

@@ -3,18 +3,22 @@ import 'package:codec2_flutter/codec2_flutter.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) { switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200; case VoicePacketMode.mode3200:
case VoicePacketMode.mode1600: return Codec2Mode.mode1600; return Codec2Mode.mode3200;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400; case VoicePacketMode.mode1600:
case VoicePacketMode.mode700c: return Codec2Mode.mode700c; return Codec2Mode.mode1600;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200; case VoicePacketMode.mode1400:
case VoicePacketMode.mode1300: return Codec2Mode.mode1300; return Codec2Mode.mode1400;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400; case VoicePacketMode.mode700c:
return Codec2Mode.mode700c;
case VoicePacketMode.mode1200:
return Codec2Mode.mode1200;
case VoicePacketMode.mode1300:
return Codec2Mode.mode1300;
case VoicePacketMode.mode2400:
return Codec2Mode.mode2400;
} }
} }
@@ -39,14 +43,11 @@ class VoiceCodecService {
} }
} }
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
/// Returns the raw Codec2-encoded bytes.
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) { Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
_ensureCodec2Supported(); _ensureCodec2Supported();
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode)); return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
} }
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) { Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
_ensureCodec2Supported(); _ensureCodec2Supported();
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode)); return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
@@ -59,24 +60,14 @@ class VoiceCodecService {
VoicePacketMode mode, VoicePacketMode mode,
) async { ) async {
_ensureCodec2Supported(); _ensureCodec2Supported();
final c2Mode = codec2ModeFor(mode);
final c2 = Codec2.create(c2Mode);
final spf = c2.samplesPerFrame;
c2.destroy();
// Estimate total samples (use actual data or silence per missing packet)
final all = <Int16List>[]; final all = <Int16List>[];
for (final pkt in packets) { for (final pkt in packets) {
if (pkt == null || pkt.codec2Data.isEmpty) { if (pkt == null || pkt.codec2Data.isEmpty) {
// Silence for missing packet — duration approximated by mode all.add(Int16List(mode.samplesPerPacket));
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
all.add(Int16List(silenceSamples));
} else { } else {
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode); all.add(await decode(pkt.codec2Data, mode));
all.add(decoded);
} }
} }
final total = all.fold<int>(0, (sum, l) => sum + l.length); final total = all.fold<int>(0, (sum, l) => sum + l.length);
final result = Int16List(total); final result = Int16List(total);
var offset = 0; var offset = 0;

View File

@@ -1,7 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import '../utils/voice_message_parser.dart'; import '../utils/voice_message_parser.dart';
/// Web/unsupported platform stub — Codec2 FFI is not available.
enum Codec2Mode { enum Codec2Mode {
mode3200(0), mode3200(0),
mode2400(1), mode2400(1),
@@ -19,13 +18,20 @@ enum Codec2Mode {
int get bytesPerSecond { int get bytesPerSecond {
switch (this) { switch (this) {
case mode3200: return 400; case mode3200:
case mode700c: return 100; return 400;
case mode1200: return 150; case mode700c:
case mode1300: return 175; return 100;
case mode1400: return 175; case mode1200:
case mode1600: return 200; return 150;
case mode2400: return 300; case mode1300:
return 175;
case mode1400:
return 175;
case mode1600:
return 200;
case mode2400:
return 300;
} }
} }
@@ -40,13 +46,20 @@ enum Codec2Mode {
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) { Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
switch (pktMode) { switch (pktMode) {
case VoicePacketMode.mode3200: return Codec2Mode.mode3200; case VoicePacketMode.mode3200:
case VoicePacketMode.mode1600: return Codec2Mode.mode1600; return Codec2Mode.mode3200;
case VoicePacketMode.mode1400: return Codec2Mode.mode1400; case VoicePacketMode.mode1600:
case VoicePacketMode.mode700c: return Codec2Mode.mode700c; return Codec2Mode.mode1600;
case VoicePacketMode.mode1200: return Codec2Mode.mode1200; case VoicePacketMode.mode1400:
case VoicePacketMode.mode1300: return Codec2Mode.mode1300; return Codec2Mode.mode1400;
case VoicePacketMode.mode2400: return Codec2Mode.mode2400; case VoicePacketMode.mode700c:
return Codec2Mode.mode700c;
case VoicePacketMode.mode1200:
return Codec2Mode.mode1200;
case VoicePacketMode.mode1300:
return Codec2Mode.mode1300;
case VoicePacketMode.mode2400:
return Codec2Mode.mode2400;
} }
} }
@@ -66,6 +79,5 @@ class VoiceCodecService {
Future<Int16List> decodePackets( Future<Int16List> decodePackets(
List<VoicePacket?> packets, List<VoicePacket?> packets,
VoicePacketMode mode, VoicePacketMode mode,
) => ) => Future.error(UnsupportedError('Voice not supported on web'));
Future.error(UnsupportedError('Voice not supported on web'));
} }

View File

@@ -5,7 +5,7 @@ import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file /// Plays decoded mono PCM samples by writing a WAV file
/// to the system temp directory and using [AudioPlayer]. /// to the system temp directory and using [AudioPlayer].
class VoicePlayerService { class VoicePlayerService {
final AudioPlayer _player = AudioPlayer(); final AudioPlayer _player = AudioPlayer();
@@ -49,16 +49,17 @@ class VoicePlayerService {
}); });
} }
/// Play [pcmSamples] (Int16, 8000 Hz, mono). Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
Future<void> play(Int16List pcmSamples) async {
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples'); debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
if (_isPlaying) await stop(); if (_isPlaying) await stop();
_position = Duration.zero; _position = Duration.zero;
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000); _duration = Duration(
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
);
_playbackStartedAt = DateTime.now(); _playbackStartedAt = DateTime.now();
_events.add(null); _events.add(null);
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000); final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
final tmpDir = await getTemporaryDirectory(); final tmpDir = await getTemporaryDirectory();
final file = File('${tmpDir.path}/vc_voice.wav'); final file = File('${tmpDir.path}/vc_voice.wav');
await file.writeAsBytes(wavBytes); await file.writeAsBytes(wavBytes);

View File

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

View File

@@ -1,27 +1,50 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
enum AppThemeMode { enum AppThemeMode { light, dark, sarRed, sarGreen, sarNavyBlue, system }
light,
dark,
sarRed,
sarGreen,
sarNavyBlue,
system,
}
class AppTheme { class AppTheme {
static ThemeData _withSharedControls(ThemeData theme) {
final colorScheme = theme.colorScheme;
return theme.copyWith(
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return colorScheme.onSurface.withValues(alpha: 0.38);
}
if (states.contains(WidgetState.selected)) {
return colorScheme.onPrimary;
}
return colorScheme.outline;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return colorScheme.onSurface.withValues(alpha: 0.12);
}
if (states.contains(WidgetState.selected)) {
return colorScheme.primary;
}
return colorScheme.surfaceContainerHighest;
}),
trackOutlineColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return colorScheme.primary;
}
return colorScheme.outlineVariant;
}),
),
);
}
// Light theme (Blue) // Light theme (Blue)
static ThemeData get lightTheme { static ThemeData get lightTheme {
return ThemeData( return _withSharedControls(
ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue, seedColor: Colors.blue,
brightness: Brightness.light, brightness: Brightness.light,
), ),
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -29,26 +52,23 @@ class AppTheme {
), ),
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder( border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(12),
),
filled: true, filled: true,
), ),
),
); );
} }
// Dark theme (Blue) // Dark theme (Blue)
static ThemeData get darkTheme { static ThemeData get darkTheme {
return ThemeData( return _withSharedControls(
ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue, seedColor: Colors.blue,
brightness: Brightness.dark, brightness: Brightness.dark,
), ),
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
@@ -56,17 +76,17 @@ class AppTheme {
), ),
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder( border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(12),
),
filled: true, filled: true,
), ),
),
); );
} }
// SAR Red theme (Emergency/Alert tones) // SAR Red theme (Emergency/Alert tones)
static ThemeData get sarRedTheme { static ThemeData get sarRedTheme {
return ThemeData( return _withSharedControls(
ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
brightness: Brightness.dark, brightness: Brightness.dark,
@@ -100,10 +120,7 @@ class AppTheme {
color: const Color(0xFF2D0000), color: const Color(0xFF2D0000),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
side: const BorderSide( side: const BorderSide(color: Color(0xFFFF5252), width: 1),
color: Color(0xFFFF5252),
width: 1,
),
), ),
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
@@ -120,12 +137,14 @@ class AppTheme {
foregroundColor: const Color(0xFF000000), foregroundColor: const Color(0xFF000000),
), ),
), ),
),
); );
} }
// SAR Green theme (All Clear/Safe tones) // SAR Green theme (All Clear/Safe tones)
static ThemeData get sarGreenTheme { static ThemeData get sarGreenTheme {
return ThemeData( return _withSharedControls(
ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
brightness: Brightness.dark, brightness: Brightness.dark,
@@ -159,10 +178,7 @@ class AppTheme {
color: const Color(0xFF002D1F), color: const Color(0xFF002D1F),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
side: const BorderSide( side: const BorderSide(color: Color(0xFF69F0AE), width: 1),
color: Color(0xFF69F0AE),
width: 1,
),
), ),
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
@@ -179,12 +195,14 @@ class AppTheme {
foregroundColor: const Color(0xFF000000), foregroundColor: const Color(0xFF000000),
), ),
), ),
),
); );
} }
// SAR Navy Blue theme (Professional/Operations tones) // SAR Navy Blue theme (Professional/Operations tones)
static ThemeData get sarNavyBlueTheme { static ThemeData get sarNavyBlueTheme {
return ThemeData( return _withSharedControls(
ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
brightness: Brightness.dark, brightness: Brightness.dark,
@@ -218,10 +236,7 @@ class AppTheme {
color: const Color(0xFF001A2D), color: const Color(0xFF001A2D),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
side: const BorderSide( side: const BorderSide(color: Color(0xFF5C9FFF), width: 1),
color: Color(0xFF5C9FFF),
width: 1,
),
), ),
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
@@ -238,6 +253,7 @@ class AppTheme {
foregroundColor: const Color(0xFF000000), foregroundColor: const Color(0xFF000000),
), ),
), ),
),
); );
} }

View File

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

View File

@@ -30,6 +30,8 @@ class ResolvedNodeHash {
final bool isOwnNode; final bool isOwnNode;
final bool isUniqueMatch; final bool isUniqueMatch;
final int matchCount; final int matchCount;
final double? latitude;
final double? longitude;
const ResolvedNodeHash({ const ResolvedNodeHash({
required this.hashHex, required this.hashHex,
@@ -37,6 +39,8 @@ class ResolvedNodeHash {
required this.isOwnNode, required this.isOwnNode,
required this.isUniqueMatch, required this.isUniqueMatch,
required this.matchCount, required this.matchCount,
this.latitude,
this.longitude,
}); });
String get hexLabel => '0x${hashHex.toUpperCase()}'; String get hexLabel => '0x${hashHex.toUpperCase()}';
@@ -154,11 +158,33 @@ class LogRxRouteDecoder {
return hops; return hops;
} }
static List<int> reverseHopBytes(
List<int> pathBytes, {
required int hashSize,
}) {
if (pathBytes.isEmpty) return const [];
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
return List<int>.from(pathBytes.reversed);
}
final reversed = <int>[];
for (
var index = pathBytes.length - hashSize;
index >= 0;
index -= hashSize
) {
reversed.addAll(pathBytes.sublist(index, index + hashSize));
}
return reversed;
}
static ResolvedNodeHash resolveHash( static ResolvedNodeHash resolveHash(
String hashHex, { String hashHex, {
required Iterable<Contact> contacts, required Iterable<Contact> contacts,
Uint8List? ownPublicKey, Uint8List? ownPublicKey,
String? ownName, String? ownName,
double? ownLatitude,
double? ownLongitude,
}) { }) {
final normalizedHashHex = hashHex.toLowerCase(); final normalizedHashHex = hashHex.toLowerCase();
final ownKeyHex = _bytesToHex(ownPublicKey); final ownKeyHex = _bytesToHex(ownPublicKey);
@@ -172,6 +198,8 @@ class LogRxRouteDecoder {
isOwnNode: true, isOwnNode: true,
isUniqueMatch: true, isUniqueMatch: true,
matchCount: 1, matchCount: 1,
latitude: ownLatitude,
longitude: ownLongitude,
); );
} }
@@ -190,12 +218,15 @@ class LogRxRouteDecoder {
} }
if (matches.length == 1) { if (matches.length == 1) {
final location = matches.first.displayLocation;
return ResolvedNodeHash( return ResolvedNodeHash(
hashHex: normalizedHashHex, hashHex: normalizedHashHex,
label: matches.first.displayName, label: matches.first.displayName,
isOwnNode: false, isOwnNode: false,
isUniqueMatch: true, isUniqueMatch: true,
matchCount: 1, matchCount: 1,
latitude: location?.latitude,
longitude: location?.longitude,
); );
} }

View File

@@ -9,14 +9,16 @@ extension MessageLocalization on Message {
/// Get localized delivery status text /// Get localized delivery status text
String getLocalizedDeliveryStatus(BuildContext context) { String getLocalizedDeliveryStatus(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final routeMetadata = context final messagesProvider = context.read<MessagesProvider>();
.read<MessagesProvider>() final routeMetadata = messagesProvider.getMessageRouteMetadata(id);
.getMessageRouteMetadata(id);
// For channel messages, show echo count instead of delivery status // For channel messages, show echo count instead of delivery status
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) { if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
final latestMeta = _formatEchoMeta(context); final latestMeta = _formatEchoMeta(context);
if (echoCount == 0) { if (echoCount == 0) {
if (messagesProvider.hasChannelSendWarning(id)) {
return 'Broadcast may have failed';
}
return l10n.broadcast; // "Broadcast (no echoes yet)" return l10n.broadcast; // "Broadcast (no echoes yet)"
} else if (echoCount == 1) { } else if (echoCount == 1) {
return latestMeta == null ? '1 node' : '1 node • $latestMeta'; return latestMeta == null ? '1 node' : '1 node • $latestMeta';

View File

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

View File

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

View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
enum _RefreshEdge { top, bottom }
class BidirectionalRefresh extends StatefulWidget {
static const double defaultTriggerDistance = 90;
final Widget child;
final Future<void> Function() onRefresh;
final double triggerDistance;
const BidirectionalRefresh({
super.key,
required this.child,
required this.onRefresh,
this.triggerDistance = defaultTriggerDistance,
});
@override
State<BidirectionalRefresh> createState() => _BidirectionalRefreshState();
}
class _BidirectionalRefreshState extends State<BidirectionalRefresh> {
double _topPullDistance = 0;
double _bottomPullDistance = 0;
bool _isRefreshing = false;
bool _handleScrollNotification(ScrollNotification notification) {
if (_isRefreshing) {
return false;
}
if (notification is ScrollStartNotification) {
_resetPullDistance();
return false;
}
if (notification is OverscrollNotification) {
final metrics = notification.metrics;
final isAtTop = metrics.extentBefore == 0;
final isAtBottom = metrics.extentAfter == 0;
if (!isAtTop && !isAtBottom) {
return false;
}
var shouldRefresh = false;
setState(() {
if (isAtTop) {
_topPullDistance += notification.overscroll.abs();
shouldRefresh = _topPullDistance >= widget.triggerDistance;
}
if (isAtBottom) {
_bottomPullDistance += notification.overscroll.abs();
shouldRefresh =
shouldRefresh || _bottomPullDistance >= widget.triggerDistance;
}
});
if (shouldRefresh) {
_runRefresh();
}
return false;
}
if (notification is ScrollEndNotification) {
_resetPullDistance();
}
return false;
}
Future<void> _runRefresh() async {
if (_isRefreshing) {
return;
}
setState(() {
_isRefreshing = true;
});
try {
await widget.onRefresh();
} finally {
if (mounted) {
setState(() {
_isRefreshing = false;
_topPullDistance = 0;
_bottomPullDistance = 0;
});
}
}
}
void _resetPullDistance() {
if (_topPullDistance == 0 && _bottomPullDistance == 0) {
return;
}
setState(() {
_topPullDistance = 0;
_bottomPullDistance = 0;
});
}
@override
Widget build(BuildContext context) {
final topProgress = (_topPullDistance / widget.triggerDistance).clamp(
0.0,
1.0,
);
final bottomProgress = (_bottomPullDistance / widget.triggerDistance).clamp(
0.0,
1.0,
);
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: Stack(
children: [
widget.child,
_RefreshIndicatorOverlay(
alignment: Alignment.topCenter,
progress: _isRefreshing ? 1 : topProgress,
visible: _isRefreshing || _topPullDistance > 0,
edge: _RefreshEdge.top,
),
_RefreshIndicatorOverlay(
alignment: Alignment.bottomCenter,
progress: _isRefreshing ? 1 : bottomProgress,
visible: _isRefreshing || _bottomPullDistance > 0,
edge: _RefreshEdge.bottom,
),
],
),
);
}
}
class _RefreshIndicatorOverlay extends StatelessWidget {
final Alignment alignment;
final double progress;
final bool visible;
final _RefreshEdge edge;
const _RefreshIndicatorOverlay({
required this.alignment,
required this.progress,
required this.visible,
required this.edge,
});
@override
Widget build(BuildContext context) {
if (!visible) {
return const SizedBox.shrink();
}
final indicator = RefreshProgressIndicator(value: progress);
final padding = edge == _RefreshEdge.top
? const EdgeInsets.only(top: 12)
: const EdgeInsets.only(bottom: 12);
return Positioned.fill(
child: IgnorePointer(
child: Align(
alignment: alignment,
child: Padding(
padding: padding,
child: SizedBox.square(dimension: 28, child: indicator),
),
),
),
);
}
}

View File

@@ -108,6 +108,8 @@ class ContactAvatar extends StatelessWidget {
return Colors.orange; return Colors.orange;
case ContactType.room: case ContactType.room:
return Colors.purple; return Colors.purple;
case ContactType.sensor:
return Colors.green;
case ContactType.channel: case ContactType.channel:
return Colors.teal; return Colors.teal;
} }
@@ -130,6 +132,8 @@ class ContactAvatar extends StatelessWidget {
return Icons.router; return Icons.router;
case ContactType.room: case ContactType.room:
return Icons.meeting_room; return Icons.meeting_room;
case ContactType.sensor:
return Icons.sensors;
case ContactType.channel: case ContactType.channel:
return Icons.public; return Icons.public;
} }

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart' show LogRxDataInfo;
class SignalMetric {
final String valueLabel;
final Color color;
final int activeBars;
const SignalMetric({
required this.valueLabel,
required this.color,
required this.activeBars,
});
static SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) {
if (rxInfo == null) return null;
return fromValues(rssiDbm: rxInfo.rssiDbm, snrDb: rxInfo.snrDb);
}
static SignalMetric? fromValues({int? rssiDbm, double? snrDb}) {
if (snrDb != null) {
return SignalMetric(
valueLabel: '${snrDb.toStringAsFixed(1)}dB',
color: snrDb >= 10
? Colors.green
: snrDb >= 0
? Colors.amber
: Colors.redAccent,
activeBars: snrDb >= 10
? 3
: snrDb >= 0
? 2
: 1,
);
}
if (rssiDbm != null) {
return SignalMetric(
valueLabel: '$rssiDbm dBm',
color: rssiDbm >= -80
? Colors.green
: rssiDbm >= -95
? Colors.amber
: Colors.redAccent,
activeBars: rssiDbm >= -80
? 3
: rssiDbm >= -95
? 2
: 1,
);
}
return null;
}
}
class CompactSignalIndicator extends StatelessWidget {
final SignalMetric metric;
final bool dense;
const CompactSignalIndicator({
super.key,
required this.metric,
this.dense = false,
});
@override
Widget build(BuildContext context) {
final inactive = Theme.of(context).colorScheme.outlineVariant;
final barWidth = dense ? 4.0 : 5.0;
final baseHeight = dense ? 8.0 : 10.0;
final barStep = dense ? 6.0 : 8.0;
final barSpacing = dense ? 2.0 : 3.0;
final labelGap = dense ? 4.0 : 6.0;
final labelFontSize = dense ? 10.0 : 12.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var index = 0; index < 3; index++) ...[
if (index > 0) SizedBox(width: barSpacing),
Container(
width: barWidth,
height: baseHeight + (index * barStep),
decoration: BoxDecoration(
color: index < metric.activeBars ? metric.color : inactive,
borderRadius: BorderRadius.circular(2),
),
),
],
],
),
SizedBox(height: labelGap),
Text(
metric.valueLabel,
style: TextStyle(
fontSize: labelFontSize,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
);
}
}

View File

@@ -1,160 +0,0 @@
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';
import '../providers/connection_provider.dart';
import '../models/sse_server_config.dart';
/// Connection Mode Selector Widget
///
/// Allows user to enable/disable SSE Server mode to share device with multiple clients
class ConnectionModeSelector extends StatefulWidget {
const ConnectionModeSelector({super.key});
@override
State<ConnectionModeSelector> createState() => _ConnectionModeSelectorState();
}
class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
List<String> _localIPs = [];
@override
void initState() {
super.initState();
_loadLocalIPs();
}
Future<void> _loadLocalIPs() async {
if (kIsWeb) {
return;
}
final Set<String> ipsSet = {};
try {
final interfaces = await NetworkInterface.list();
for (final interface in interfaces) {
for (final addr in interface.addresses) {
if (addr.type == InternetAddressType.IPv4 && !addr.isLoopback) {
ipsSet.add(addr.address);
}
}
}
} catch (e) {
debugPrint('Error getting network interfaces: $e');
}
if (mounted) {
setState(() {
_localIPs = ipsSet.toList();
});
}
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
final connectionProvider = Provider.of<ConnectionProvider>(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Header
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
'Network Sharing',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
// SSE Server Toggle
SwitchListTile(
secondary: const Icon(Icons.share),
title: const Text('Share Device (Server)'),
subtitle: Text(
connectionProvider.isSseServerRunning
? 'Server running on port ${connectionProvider.sseServerConfig.port} - ${connectionProvider.sseClientCount} client(s) connected'
: 'Share BLE device with multiple clients over network',
),
value: connectionProvider.isSseServerRunning,
onChanged: (enabled) async {
if (enabled) {
// Start server with default config (port 12929, no auth)
final config = const SseServerConfig(port: 12929, enabled: true);
try {
await connectionProvider.startSseServer(config);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('SSE server started on port 12929'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to start server: $e'),
backgroundColor: Colors.red,
),
);
}
}
} else {
// Stop server
await connectionProvider.stopSseServer();
}
},
),
// Show IP addresses when server is running
if (connectionProvider.isSseServerRunning && _localIPs.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Text(
'Connect from other devices:',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
..._localIPs.map((ip) {
final url = 'http://$ip:${connectionProvider.sseServerConfig.port}';
return ListTile(
dense: true,
leading: const Icon(Icons.wifi, size: 20),
title: Text(
url,
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
),
trailing: IconButton(
icon: const Icon(Icons.copy, size: 20),
tooltip: 'Copy URL',
onPressed: () {
Clipboard.setData(ClipboardData(text: url));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Copied $url'),
duration: const Duration(seconds: 1),
),
);
},
),
);
}),
],
],
);
}
}

View File

@@ -1,20 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
/// Dialog for adding a new channel /// Bottom sheet for adding a new channel
class AddChannelDialog extends StatefulWidget { class AddChannelSheet extends StatefulWidget {
final Future<void> Function(String name, String secret) onCreateChannel; final Future<void> Function(String name, String secret) onCreateChannel;
const AddChannelDialog({ const AddChannelSheet({super.key, required this.onCreateChannel});
super.key,
required this.onCreateChannel,
});
@override @override
State<AddChannelDialog> createState() => _AddChannelDialogState(); State<AddChannelSheet> createState() => _AddChannelSheetState();
} }
class _AddChannelDialogState extends State<AddChannelDialog> { class _AddChannelSheetState extends State<AddChannelSheet> {
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController(); final _nameController = TextEditingController();
final _secretController = TextEditingController(); final _secretController = TextEditingController();
@@ -35,16 +32,17 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
/// Validate channel name /// Validate channel name
String? _validateName(String? value) { String? _validateName(String? value) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final trimmedValue = value?.trim() ?? '';
if (value == null || value.trim().isEmpty) { if (trimmedValue.isEmpty) {
return l10n.channelNameRequired; return l10n.channelNameRequired;
} }
if (value.length > 31) { if (trimmedValue.length > 31) {
return l10n.channelNameTooLong; return l10n.channelNameTooLong;
} }
if (!_isAscii(value)) { if (!_isAscii(trimmedValue)) {
return l10n.invalidAsciiCharacters; return l10n.invalidAsciiCharacters;
} }
@@ -105,23 +103,49 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context); final theme = Theme.of(context);
final isHashChannel = _nameController.text.startsWith('#'); final normalizedName = _nameController.text.trimLeft();
final isHashChannel = normalizedName.startsWith('#');
return AlertDialog( return ConstrainedBox(
title: Text(l10n.addChannel), constraints: BoxConstraints(
content: SingleChildScrollView( maxHeight: MediaQuery.of(context).size.height * 0.85,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Info banner explaining channel types Row(
children: [
Expanded(
child: Text(
l10n.addChannel,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
onPressed: _isCreating
? null
: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
tooltip: l10n.close,
),
],
),
const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.3), color: theme.colorScheme.primaryContainer.withValues(
borderRadius: BorderRadius.circular(8), alpha: 0.3,
),
borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.3), color: theme.colorScheme.primary.withValues(alpha: 0.3),
), ),
@@ -147,8 +171,6 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Channel Name Field
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
decoration: InputDecoration( decoration: InputDecoration(
@@ -163,10 +185,16 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
enabled: !_isCreating, enabled: !_isCreating,
maxLength: 31, maxLength: 31,
validator: _validateName, validator: _validateName,
textInputAction: TextInputAction.next, textInputAction: isHashChannel
onChanged: (_) => setState(() {}), // Rebuild to update icon ? TextInputAction.done
: TextInputAction.next,
onChanged: (_) => setState(() {}),
onFieldSubmitted: (_) {
if (isHashChannel) {
_handleCreate();
}
},
), ),
// Channel Secret Field (only show for private channels)
if (!isHashChannel) ...[ if (!isHashChannel) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
TextFormField( TextFormField(
@@ -184,7 +212,6 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
onFieldSubmitted: (_) => _handleCreate(), onFieldSubmitted: (_) => _handleCreate(),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// Help Text for private channels
Text( Text(
l10n.channelSecretHelp, l10n.channelSecretHelp,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
@@ -192,15 +219,15 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
), ),
), ),
], ],
// Help Text for hash channels
if (isHashChannel) ...[ if (isHashChannel) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.5), color: theme.colorScheme.primaryContainer.withValues(
borderRadius: BorderRadius.circular(8), alpha: 0.5,
),
borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
children: [ children: [
@@ -223,29 +250,39 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
), ),
), ),
], ],
], const SizedBox(height: 20),
), Row(
), children: [
), Expanded(
actions: [ child: OutlinedButton(
// Cancel Button onPressed: _isCreating
TextButton( ? null
onPressed: _isCreating ? null : () => Navigator.of(context).pop(), : () => Navigator.of(context).pop(),
child: Text(l10n.cancel), child: Text(l10n.cancel),
), ),
),
// Create Button const SizedBox(width: 12),
FilledButton( Expanded(
child: FilledButton(
onPressed: _isCreating ? null : _handleCreate, onPressed: _isCreating ? null : _handleCreate,
child: _isCreating child: _isCreating
? const SizedBox( ? const SizedBox(
width: 16, width: 16,
height: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(
strokeWidth: 2,
),
) )
: Text(l10n.createChannel), : Text(l10n.createChannel),
), ),
),
], ],
),
],
),
),
),
),
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -13,13 +13,17 @@ import '../../providers/messages_provider.dart';
import '../../providers/sensors_provider.dart'; import '../../providers/sensors_provider.dart';
import '../../services/message_destination_preferences.dart'; import '../../services/message_destination_preferences.dart';
import 'contact_route_dialog.dart'; import 'contact_route_dialog.dart';
import 'contact_trace_sheet.dart';
import 'room_login_sheet.dart'; import 'room_login_sheet.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
import '../sensors/sensor_telemetry_card.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
class ContactTile extends StatelessWidget { class ContactTile extends StatelessWidget {
final Contact contact; final Contact contact;
final String? groupLabel;
final bool compact;
final Position? currentPosition; final Position? currentPosition;
final double Function(double, double, double, double)? calculateDistance; final double Function(double, double, double, double)? calculateDistance;
final String Function(double)? formatDistance; final String Function(double)? formatDistance;
@@ -29,6 +33,8 @@ class ContactTile extends StatelessWidget {
const ContactTile({ const ContactTile({
super.key, super.key,
required this.contact, required this.contact,
this.groupLabel,
this.compact = false,
this.currentPosition, this.currentPosition,
this.calculateDistance, this.calculateDistance,
this.formatDistance, this.formatDistance,
@@ -36,10 +42,9 @@ class ContactTile extends StatelessWidget {
this.onNavigateToMessages, this.onNavigateToMessages,
}); });
/// Get localized time since last seen String _getLocalizedRelativeTime(BuildContext context, DateTime when) {
String _getLocalizedTimeSinceLastSeen(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(contact.lastSeenTime); final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow; if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
@@ -50,6 +55,8 @@ class ContactTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isChannel = contact.type == ContactType.channel; final isChannel = contact.type == ContactType.channel;
final isRoom = contact.type == ContactType.room;
final isRoomOrChannel = isRoom || isChannel;
final location = contact.displayLocation; final location = contact.displayLocation;
// Calculate distance if both positions are available // Calculate distance if both positions are available
String? distanceText; String? distanceText;
@@ -68,6 +75,7 @@ class ContactTile extends StatelessWidget {
// Get room login state if this is a room // Get room login state if this is a room
final connectionProvider = context.watch<ConnectionProvider>(); final connectionProvider = context.watch<ConnectionProvider>();
final messagesProvider = context.watch<MessagesProvider>();
final isPingInProgress = connectionProvider.isPingInProgress( final isPingInProgress = connectionProvider.isPingInProgress(
contact.publicKey, contact.publicKey,
); );
@@ -109,18 +117,37 @@ class ContactTile extends StatelessWidget {
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: -0.3, letterSpacing: -0.3,
); );
final timeAgoText = _getLocalizedTimeSinceLastSeen(context); final lastActivityAt = isRoomOrChannel
? messagesProvider.getLastActivityForDestination(contact)
: null;
final timeAgoText = _getLocalizedRelativeTime(
context,
lastActivityAt ?? contact.lastSeenTime,
);
final timeAgoStyle = Theme.of(context).textTheme.labelSmall?.copyWith( final timeAgoStyle = Theme.of(context).textTheme.labelSmall?.copyWith(
color: contact.isRecentlySeen color: contact.isRecentlySeen
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
final Widget? subtitleWidget = isChannel final Widget subtitleWidget = compact
? null ? _buildCompactSubtitle(context, distanceText)
: Column( : Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (groupLabel case final label?)
_buildMetaPill(
context,
icon: Icons.folder_copy_outlined,
label: label,
),
],
),
if (location != null) ...[ if (location != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
_buildLocationLine( _buildLocationLine(
@@ -239,7 +266,7 @@ class ContactTile extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
if (!isChannel) if (!isRoomOrChannel || lastActivityAt != null)
Text(timeAgoText, style: timeAgoStyle), Text(timeAgoText, style: timeAgoStyle),
if (isPingInProgress) ...[ if (isPingInProgress) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
@@ -254,7 +281,7 @@ class ContactTile extends StatelessWidget {
], ],
], ],
), ),
?subtitleWidget, subtitleWidget,
], ],
), ),
), ),
@@ -266,6 +293,28 @@ class ContactTile extends StatelessWidget {
); );
} }
Widget _buildCompactSubtitle(BuildContext context, String? distanceText) {
final location = contact.displayLocation;
final compactPills = <Widget>[
if (groupLabel case final label?)
_buildMetaPill(context, icon: Icons.folder_copy_outlined, label: label),
if (distanceText != null) _buildDistancePill(context, distanceText),
if (contact.routeHasPath && contact.routeHopCount > 0)
_buildRoutePill(context, contact),
if (location == null)
_buildMetaPill(
context,
icon: Icons.location_disabled_outlined,
label: AppLocalizations.of(context)!.noGpsData,
),
];
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(spacing: 6, runSpacing: 6, children: compactPills),
);
}
void _handlePrimaryTap(BuildContext context, Contact contact) { void _handlePrimaryTap(BuildContext context, Contact contact) {
_showContactActionSheet(context, contact); _showContactActionSheet(context, contact);
} }
@@ -277,10 +326,15 @@ class ContactTile extends StatelessWidget {
contact.type == ContactType.room || contact.type == ContactType.room ||
contact.type == ContactType.channel; contact.type == ContactType.channel;
final canSetPath = final canSetPath =
contact.type == ContactType.chat || contact.type == ContactType.room; contact.type == ContactType.chat ||
contact.type == ContactType.room ||
contact.type == ContactType.repeater ||
contact.type == ContactType.sensor;
final canAddToSensors = final canAddToSensors =
contact.type == ContactType.chat || contact.type == ContactType.chat ||
contact.type == ContactType.repeater; contact.type == ContactType.repeater ||
contact.type == ContactType.sensor;
final canPreviewSensor = contact.isSensor;
final sensorsProvider = context.read<SensorsProvider>(); final sensorsProvider = context.read<SensorsProvider>();
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex); final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
@@ -290,6 +344,7 @@ class ContactTile extends StatelessWidget {
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
), ),
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -330,12 +385,27 @@ class ContactTile extends StatelessWidget {
_showRoomLoginDialog(context, contact); _showRoomLoginDialog(context, contact);
}, },
), ),
if (canPreviewSensor)
ListTile(
leading: const Icon(Icons.visibility_outlined),
title: const Text('Preview'),
onTap: () async {
Navigator.pop(sheetContext);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
await _showSensorPreviewView(context, contact);
},
),
if (canAddToSensors) if (canAddToSensors)
ListTile( ListTile(
leading: Icon( leading: Icon(
isInSensors ? Icons.sensors : Icons.sensors_outlined, isInSensors ? Icons.sensors : Icons.sensors_outlined,
), ),
title: Text(isInSensors ? 'In Sensors' : 'Add to Sensors'), title: Text(
isInSensors
? l10n.contactInSensors
: l10n.contactAddToSensors,
),
enabled: !isInSensors, enabled: !isInSensors,
onTap: isInSensors onTap: isInSensors
? null ? null
@@ -347,12 +417,30 @@ class ContactTile extends StatelessWidget {
if (canSetPath) if (canSetPath)
ListTile( ListTile(
leading: const Icon(Icons.alt_route), leading: const Icon(Icons.alt_route),
title: const Text('Set path'), title: Text(l10n.contactSetPath),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_showSetRouteDialog(context, contact); _showSetRouteDialog(context, contact);
}, },
), ),
if (!contact.isChannel)
ListTile(
leading: const Icon(Icons.route),
title: const Text('Trace'),
onTap: () {
Navigator.pop(sheetContext);
_showTraceSheet(context, contact);
},
),
if (!contact.isPublicChannel)
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Edit name'),
onTap: () {
Navigator.pop(sheetContext);
_showNameOverrideDialog(context, contact);
},
),
if (!contact.isPublicChannel) if (!contact.isPublicChannel)
ListTile( ListTile(
leading: const Icon(Icons.delete, color: Colors.red), leading: const Icon(Icons.delete, color: Colors.red),
@@ -360,18 +448,36 @@ class ContactTile extends StatelessWidget {
contact.isChannel ? l10n.deleteChannel : l10n.deleteContact, contact.isChannel ? l10n.deleteChannel : l10n.deleteContact,
style: const TextStyle(color: Colors.red), style: const TextStyle(color: Colors.red),
), ),
onTap: () { onTap: () async {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
if (contact.isChannel) { if (contact.isChannel) {
_showDeleteChannelDialog(context, contact); _showDeleteChannelDialog(context, contact);
} else { } else {
_showDeleteConfirmation(context, contact); _showDeleteConfirmation(context, contact);
} }
});
}, },
), ),
], ],
), ),
), ),
),
);
}
Future<void> _showSensorPreviewView(
BuildContext context,
Contact contact,
) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (pageContext) => _SensorPreviewView(contact: contact),
),
); );
} }
@@ -397,7 +503,13 @@ class ContactTile extends StatelessWidget {
} }
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${contact.displayName} added to Sensors')), SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.contactAddedToSensors(contact.displayName),
),
),
); );
} }
@@ -432,6 +544,18 @@ class ContactTile extends StatelessWidget {
); );
} }
void _showTraceSheet(BuildContext context, Contact contact) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => ContactTraceSheet(contact: contact),
);
}
void _showDeleteConfirmation( void _showDeleteConfirmation(
BuildContext context, BuildContext context,
Contact contact, { Contact contact, {
@@ -491,6 +615,40 @@ class ContactTile extends StatelessWidget {
} }
} }
Future<void> _showNameOverrideDialog(
BuildContext context,
Contact contact,
) async {
final l10n = AppLocalizations.of(context)!;
final result = await showModalBottomSheet<String?>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (dialogContext) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(dialogContext).viewInsets.bottom,
),
child: _ContactNameOverrideSheet(
initialValue: contact.nameOverride ?? '',
advertisedName: contact.advName,
cancelLabel: l10n.cancel,
saveLabel: l10n.save,
),
),
),
);
if (result == null || !context.mounted) {
return;
}
context.read<ContactsProvider>().setContactNameOverride(
contact.publicKeyHex,
result,
);
}
Future<void> _showSetRouteDialog( Future<void> _showSetRouteDialog(
BuildContext context, BuildContext context,
Contact contact, Contact contact,
@@ -523,15 +681,22 @@ class ContactTile extends StatelessWidget {
paddedPathBytes: previousPathBytes, paddedPathBytes: previousPathBytes,
); );
if (context.mounted) { if (context.mounted) {
ToastLogger.error(context, 'Failed to clear route: $error'); ToastLogger.error(
context,
AppLocalizations.of(
context,
)!.contactFailedToClearRoute(error.toString()),
);
} }
return; return;
} }
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(const SnackBar(content: Text('Route cleared'))); content: Text(AppLocalizations.of(context)!.contactRouteCleared),
),
);
} }
return; return;
} }
@@ -541,6 +706,7 @@ class ContactTile extends StatelessWidget {
contact.publicKey, contact.publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen, signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes, paddedPathBytes: parsedRoute.paddedPathBytes,
inferredFallbackLocation: routeResult.inferredFallbackLocation,
); );
try { try {
@@ -551,7 +717,13 @@ class ContactTile extends StatelessWidget {
); );
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Route set: ${parsedRoute.canonicalText}')), SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.contactRouteSet(parsedRoute.canonicalText),
),
),
); );
} }
} catch (error) { } catch (error) {
@@ -561,7 +733,12 @@ class ContactTile extends StatelessWidget {
paddedPathBytes: previousPathBytes, paddedPathBytes: previousPathBytes,
); );
if (context.mounted) { if (context.mounted) {
ToastLogger.error(context, 'Failed to set route: $error'); ToastLogger.error(
context,
AppLocalizations.of(
context,
)!.contactFailedToSetRoute(error.toString()),
);
} }
} }
} }
@@ -610,6 +787,38 @@ class ContactTile extends StatelessWidget {
); );
} }
Widget _buildMetaPill(
BuildContext context, {
required IconData icon,
required String label,
bool monospace = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
fontFamily: monospace ? 'monospace' : null,
),
),
],
),
);
}
Widget _buildLocationLine( Widget _buildLocationLine(
BuildContext context, { BuildContext context, {
required double latitude, required double latitude,
@@ -799,3 +1008,159 @@ class ContactTile extends StatelessWidget {
); );
} }
} }
class _SensorPreviewView extends StatelessWidget {
final Contact contact;
const _SensorPreviewView({required this.contact});
@override
Widget build(BuildContext context) {
final publicKeyHex = contact.publicKeyHex;
return Consumer2<ContactsProvider, SensorsProvider>(
builder: (context, contactsProvider, sensorsProvider, child) {
Contact? liveContact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
liveContact = entry;
break;
}
}
final previewContact = liveContact ?? contact;
final visibleFields = sensorMetricKeysFor(previewContact);
final fieldOrder = sensorsProvider.metricOrderFor(
publicKeyHex,
visibleFields,
);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(previewContact.displayName),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
SensorTelemetryCard(
contact: previewContact,
state: sensorsProvider.stateFor(publicKeyHex),
visibleFields: visibleFields,
fieldOrder: fieldOrder,
labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex),
fieldSpans: sensorFullWidthFieldSpans(visibleFields),
margin: EdgeInsets.zero,
emptyMetricsMessage: 'No telemetry fields available yet.',
),
],
),
);
},
);
}
}
class _ContactNameOverrideSheet extends StatefulWidget {
final String initialValue;
final String advertisedName;
final String cancelLabel;
final String saveLabel;
const _ContactNameOverrideSheet({
required this.initialValue,
required this.advertisedName,
required this.cancelLabel,
required this.saveLabel,
});
@override
State<_ContactNameOverrideSheet> createState() =>
_ContactNameOverrideSheetState();
}
class _ContactNameOverrideSheetState extends State<_ContactNameOverrideSheet> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
Navigator.of(context).pop(_controller.text);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Edit name',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _controller,
autofocus: true,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: 'Custom name',
hintText: widget.advertisedName,
helperText: 'Leave blank to use the advertised name.',
border: const OutlineInputBorder(),
),
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(widget.cancelLabel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _submit,
child: Text(widget.saveLabel),
),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,534 @@
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;
_ContactTraceResult? _traceOverride;
@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 = _traceOverride ?? 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
.map((entry) => entry.node)
.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
? 'No relay path saved for ${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:
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(
'Relay path',
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(
onTap: entry.value.resolved.canCycle
? () => setState(() {
final baseTrace =
_traceOverride ?? snapshot.data!;
_traceOverride = baseTrace.cycleEntry(
entry.value.target,
);
})
: null,
leading: CircleAvatar(
radius: 14,
backgroundColor: 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(
'Relay${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}',
),
trailing: entry.value.resolved.canCycle
? const Icon(Icons.sync_alt)
: null,
),
),
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) {
return 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,
target: _RouteEntryTarget.relayNode(entry.key),
);
}).toList();
}
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();
final alignedRelayNodes = TraceNodeResolver.alignPathSelections(
nodes: matchedRelayNodes,
startNode: senderNode.node,
endNode: recipientNode.node,
);
return _ContactTraceResult(
sender: senderNode,
recipient: recipientNode,
routeHashes: routeHashes,
matchedRelayNodes: alignedRelayNodes,
);
}
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,
});
_ContactTraceResult cycleEntry(_RouteEntryTarget target) {
switch (target.kind) {
case _RouteEntryKind.relayNode:
final updated = matchedRelayNodes.toList();
updated[target.index] = updated[target.index].cycle();
return _ContactTraceResult(
sender: sender,
recipient: recipient,
routeHashes: routeHashes,
matchedRelayNodes: updated,
);
}
}
}
class _RouteDisplayEntry {
final ResolvedTraceNode resolved;
final String label;
final String? keyLabel;
final String? matchSummary;
final _RouteEntryTarget target;
const _RouteDisplayEntry({
required this.resolved,
required this.label,
required this.keyLabel,
required this.matchSummary,
required this.target,
});
MeshMapNode? get node => resolved.node;
}
enum _RouteEntryKind { relayNode }
class _RouteEntryTarget {
final _RouteEntryKind kind;
final int index;
const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.relayNode(int index)
: this._(_RouteEntryKind.relayNode, index);
}

View File

@@ -147,6 +147,10 @@ class _RoomLoginSheetState extends State<RoomLoginSheet> {
try { try {
// Manually add the room contact to the radio's flash storage // Manually add the room contact to the radio's flash storage
await connectionProvider.addOrUpdateContact(widget.contact); await connectionProvider.addOrUpdateContact(widget.contact);
final addError = connectionProvider.error;
if (addError != null) {
throw Exception(addError);
}
debugPrint( debugPrint(
'✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT', '✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT',

View File

@@ -42,6 +42,7 @@ class CompassContactList extends StatelessWidget {
// Split contacts by type // Split contacts by type
final persons = <Map<String, dynamic>>[]; final persons = <Map<String, dynamic>>[];
final repeaters = <Map<String, dynamic>>[]; final repeaters = <Map<String, dynamic>>[];
final sensors = <Map<String, dynamic>>[];
final rooms = <Map<String, dynamic>>[]; final rooms = <Map<String, dynamic>>[];
// Calculate bearings and distances for each contact // Calculate bearings and distances for each contact
@@ -70,6 +71,8 @@ class CompassContactList extends StatelessWidget {
if (contact.isRepeater) { if (contact.isRepeater) {
repeaters.add(item); repeaters.add(item);
} else if (contact.isSensor) {
sensors.add(item);
} else if (contact.isRoom) { } else if (contact.isRoom) {
rooms.add(item); rooms.add(item);
} else { } else {
@@ -78,9 +81,18 @@ class CompassContactList extends StatelessWidget {
} }
// Sort each list by distance // Sort each list by distance
persons.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double)); persons.sort(
repeaters.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double)); (a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
rooms.sort((a, b) => (a['distance'] as double).compareTo(b['distance'] as double)); );
repeaters.sort(
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
);
sensors.sort(
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
);
rooms.sort(
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -91,17 +103,34 @@ class CompassContactList extends StatelessWidget {
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4), padding: const EdgeInsets.only(left: 16, bottom: 8, top: 4),
child: Text( child: Text(
l10n.teamMembers, l10n.teamMembers,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
), ),
), ...persons.map(
...persons.map((item) => _buildContactTile( (item) => _buildContactTile(
context, context,
item, item,
Icons.groups, Icons.groups,
Theme.of(context).colorScheme.primary, Theme.of(context).colorScheme.primary,
)), ),
),
],
if (showContacts && sensors.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
'Sensors',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
...sensors.map(
(item) =>
_buildContactTile(context, item, Icons.sensors, Colors.green),
),
], ],
// Repeaters section // Repeaters section
if (showRepeaters && repeaters.isNotEmpty) ...[ if (showRepeaters && repeaters.isNotEmpty) ...[
@@ -109,17 +138,15 @@ class CompassContactList extends StatelessWidget {
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12), padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text( child: Text(
l10n.repeaters, l10n.repeaters,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold,
),
),
),
...repeaters.map((item) => _buildContactTile(
context, context,
item, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
Icons.router, ),
Colors.purple, ),
)), ...repeaters.map(
(item) =>
_buildContactTile(context, item, Icons.router, Colors.purple),
),
], ],
// Rooms section // Rooms section
if (rooms.isNotEmpty) ...[ if (rooms.isNotEmpty) ...[
@@ -127,17 +154,19 @@ class CompassContactList extends StatelessWidget {
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12), padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text( child: Text(
l10n.rooms, l10n.rooms,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
), ),
), ),
), ...rooms.map(
...rooms.map((item) => _buildContactTile( (item) => _buildContactTile(
context, context,
item, item,
Icons.meeting_room, Icons.meeting_room,
Colors.teal, Colors.teal,
)), ),
),
], ],
], ],
); );
@@ -161,24 +190,14 @@ class CompassContactList extends StatelessWidget {
: Theme.of(context).colorScheme.surfaceContainerHighest, : Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: selectedContact == contact border: selectedContact == contact
? Border.all( ? Border.all(color: Theme.of(context).colorScheme.primary, width: 2)
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null, : null,
), ),
child: ListTile( child: ListTile(
dense: true, dense: true,
leading: contact.roleEmoji != null leading: contact.roleEmoji != null
? Text( ? Text(contact.roleEmoji!, style: const TextStyle(fontSize: 24))
contact.roleEmoji!, : Icon(defaultIcon, color: iconColor, size: 24),
style: const TextStyle(fontSize: 24),
)
: Icon(
defaultIcon,
color: iconColor,
size: 24,
),
title: Text(contact.displayName), title: Text(contact.displayName),
subtitle: Text( subtitle: Text(
'${_bearingToCardinal(bearing)}${_formatDistance(distance)}', '${_bearingToCardinal(bearing)}${_formatDistance(distance)}',
@@ -190,16 +209,16 @@ class CompassContactList extends StatelessWidget {
children: [ children: [
Text( Text(
'${bearing.round()}°', '${bearing.round()}°',
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold),
), ),
if (heading != null) if (heading != null)
Text( Text(
_formatRelativeBearing(bearing, heading!, context), _formatRelativeBearing(bearing, heading!, context),
style: Theme.of(context).textTheme.labelSmall?.copyWith( style: Theme.of(
color: Colors.grey, context,
), ).textTheme.labelSmall?.copyWith(color: Colors.grey),
), ),
], ],
), ),
@@ -217,15 +236,14 @@ class CompassContactList extends StatelessWidget {
} }
// Calculate bearing between two points (in degrees) // Calculate bearing between two points (in degrees)
double _calculateBearing( double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180; final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180; final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad); final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) - final x =
sin(lat1Rad) * cos(lat2Rad) * cos(dLon); cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi; final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360; return (bearing + 360) % 360;
@@ -233,12 +251,17 @@ class CompassContactList extends StatelessWidget {
// Calculate distance between two points (in meters) // Calculate distance between two points (in meters)
double _calculateDistance( double _calculateDistance(
double lat1, double lon1, double lat2, double lon2) { double lat1,
double lon1,
double lat2,
double lon2,
) {
const R = 6371000; // Earth's radius in meters const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180; final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180; final dLon = (lon2 - lon1) * pi / 180;
final a = sin(dLat / 2) * sin(dLat / 2) + final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) * cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) * cos(lat2 * pi / 180) *
sin(dLon / 2) * sin(dLon / 2) *
@@ -262,7 +285,11 @@ class CompassContactList extends StatelessWidget {
} }
} }
String _formatRelativeBearing(double bearing, double heading, BuildContext context) { String _formatRelativeBearing(
double bearing,
double heading,
BuildContext context,
) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
// Calculate relative bearing (how much to turn from current heading) // Calculate relative bearing (how much to turn from current heading)
double relative = bearing - heading; double relative = bearing - heading;

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/location_trail.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../services/gpx_service.dart';
import '../../services/trail_color_service.dart'; import '../../services/trail_color_service.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -66,6 +69,31 @@ class TrailControls extends StatelessWidget {
const Divider(), const Divider(),
const SizedBox(height: 8), 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 // Trail stats
if (mapProvider.currentTrail != null && if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.isNotEmpty) mapProvider.currentTrail!.points.isNotEmpty)
@@ -104,8 +132,6 @@ class TrailControls extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16),
// Clear trail button // Clear trail button
if (mapProvider.currentTrail != null && if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.isNotEmpty) 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( subtitle: Text(
'${contact.advertHistory.length} points', '${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( void _showClearConfirmation(
BuildContext context, BuildContext context,
MapProvider mapProvider, MapProvider mapProvider,

View File

@@ -12,7 +12,8 @@ class MapMarkers {
Function(Contact)? onContactTap, Function(Contact)? onContactTap,
double mapRotation = 0, double mapRotation = 0,
}) { }) {
return contacts.map((contact) { return contacts
.map((contact) {
final location = contact.displayLocation; final location = contact.displayLocation;
if (location == null) return null; if (location == null) return null;
@@ -36,7 +37,10 @@ class MapMarkers {
children: [ children: [
// Location update time indicator // Location update time indicator
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getLocationAgeColor(contact), color: _getLocationAgeColor(contact),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
@@ -81,7 +85,10 @@ class MapMarkers {
// Name label (without emoji) // Name label (without emoji)
Container( Container(
constraints: const BoxConstraints(maxWidth: 80), constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7), color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
@@ -103,7 +110,9 @@ class MapMarkers {
), ),
), ),
); );
}).whereType<Marker>().toList(); })
.whereType<Marker>()
.toList();
} }
static List<Marker> createSarMarkers( static List<Marker> createSarMarkers(
@@ -133,7 +142,10 @@ class MapMarkers {
children: [ children: [
// Time ago label // Time ago label
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getSarMarkerColor(marker), color: _getSarMarkerColor(marker),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
@@ -172,7 +184,10 @@ class MapMarkers {
// Type label // Type label
Container( Container(
constraints: const BoxConstraints(maxWidth: 90), constraints: const BoxConstraints(maxWidth: 90),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7), color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
@@ -183,8 +198,12 @@ class MapMarkers {
debugPrint('🗺️ [MapMarker] Displaying SAR marker:'); debugPrint('🗺️ [MapMarker] Displaying SAR marker:');
debugPrint(' marker.notes: "${marker.notes}"'); debugPrint(' marker.notes: "${marker.notes}"');
debugPrint(' marker.type: ${marker.type}'); debugPrint(' marker.type: ${marker.type}');
debugPrint(' marker.type.displayName: ${marker.type.displayName}'); debugPrint(
debugPrint(' marker.displayName: ${marker.displayName}'); ' marker.type.displayName: ${marker.type.displayName}',
);
debugPrint(
' marker.displayName: ${marker.displayName}',
);
return Text( return Text(
marker.displayName, marker.displayName,
@@ -247,11 +266,19 @@ class MapMarkers {
_InfoRow('Battery', '${contact.displayBattery!.round()}%'), _InfoRow('Battery', '${contact.displayBattery!.round()}%'),
if (contact.telemetry?.temperature != null) if (contact.telemetry?.temperature != null)
_InfoRow( _InfoRow(
'Temperature', '${contact.telemetry!.temperature!.toStringAsFixed(1)}°C'), 'Temperature',
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
),
if (contact.telemetry?.humidity != null) if (contact.telemetry?.humidity != null)
_InfoRow('Humidity', '${contact.telemetry!.humidity!.toStringAsFixed(1)}%'), _InfoRow(
'Humidity',
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
),
if (contact.telemetry?.pressure != null) if (contact.telemetry?.pressure != null)
_InfoRow('Pressure', '${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa'), _InfoRow(
'Pressure',
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
),
_InfoRow('Last Seen', contact.timeSinceLastSeen), _InfoRow('Last Seen', contact.timeSinceLastSeen),
_InfoRow('Public Key', contact.publicKeyShort), _InfoRow('Public Key', contact.publicKeyShort),
], ],
@@ -272,7 +299,10 @@ class MapMarkers {
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Row( title: Row(
children: [ children: [
Text(marker.emoji, style: const TextStyle(fontSize: 24)), // Use custom emoji if available Text(
marker.emoji,
style: const TextStyle(fontSize: 24),
), // Use custom emoji if available
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: Text(marker.displayName)), Expanded(child: Text(marker.displayName)),
], ],
@@ -313,7 +343,9 @@ class MapMarkers {
static Color _getSarMarkerColor(SarMarker marker) { static Color _getSarMarkerColor(SarMarker marker) {
// If marker has a color index, use it (new format) // If marker has a color index, use it (new format)
if (marker.colorIndex != null && marker.colorIndex! >= 0 && marker.colorIndex! < 8) { if (marker.colorIndex != null &&
marker.colorIndex! >= 0 &&
marker.colorIndex! < 8) {
final colorHex = SarTemplate.getColorFromIndex(marker.colorIndex!); final colorHex = SarTemplate.getColorFromIndex(marker.colorIndex!);
final hexCode = colorHex.replaceAll('#', ''); final hexCode = colorHex.replaceAll('#', '');
return Color(int.parse('FF$hexCode', radix: 16)); return Color(int.parse('FF$hexCode', radix: 16));
@@ -342,6 +374,8 @@ class MapMarkers {
return Colors.deepPurple; // Purple for repeaters return Colors.deepPurple; // Purple for repeaters
case ContactType.room: case ContactType.room:
return Colors.teal; // Teal for rooms return Colors.teal; // Teal for rooms
case ContactType.sensor:
return Colors.green; // Green for sensors
case ContactType.channel: case ContactType.channel:
return Colors.orange; // Orange for channels return Colors.orange; // Orange for channels
case ContactType.none: case ContactType.none:
@@ -357,6 +391,8 @@ class MapMarkers {
return Icons.router; // Router icon for repeaters return Icons.router; // Router icon for repeaters
case ContactType.room: case ContactType.room:
return Icons.forum; // Forum/chat icon for rooms return Icons.forum; // Forum/chat icon for rooms
case ContactType.sensor:
return Icons.sensors; // Sensors icon for sensor nodes
case ContactType.channel: case ContactType.channel:
return Icons.public; // Public icon for channels return Icons.public; // Public icon for channels
case ContactType.none: case ContactType.none:
@@ -385,9 +421,7 @@ class _InfoRow extends StatelessWidget {
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(fontWeight: FontWeight.w600),
), ),
), ),
Expanded( Expanded(child: Text(value)),
child: Text(value),
),
], ],
), ),
); );

View File

@@ -45,6 +45,7 @@ import 'system_message_bubble.dart';
/// - Grouped messages (expandable recipient list) /// - Grouped messages (expandable recipient list)
class MessageBubble extends StatefulWidget { class MessageBubble extends StatefulWidget {
final Message message; final Message message;
final int receivedCopies;
final VoidCallback? onTap; final VoidCallback? onTap;
final VoidCallback? onReply; final VoidCallback? onReply;
final bool isHighlighted; final bool isHighlighted;
@@ -56,6 +57,7 @@ class MessageBubble extends StatefulWidget {
const MessageBubble({ const MessageBubble({
super.key, super.key,
required this.message, required this.message,
this.receivedCopies = 1,
this.onTap, this.onTap,
this.onReply, this.onReply,
this.isHighlighted = false, this.isHighlighted = false,
@@ -68,6 +70,7 @@ class MessageBubble extends StatefulWidget {
} }
class _MessageBubbleState extends State<MessageBubble> { class _MessageBubbleState extends State<MessageBubble> {
static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]');
bool _isExpanded = false; bool _isExpanded = false;
bool _showReceivedStats = false; bool _showReceivedStats = false;
@@ -95,6 +98,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}) { void _handleBubbleTap({required bool isSarMarker, required bool isDrawing}) {
if (!widget.message.isRead && if (!widget.message.isRead &&
!widget.message.isSentMessage && !widget.message.isSentMessage &&
@@ -507,6 +578,7 @@ class _MessageBubbleState extends State<MessageBubble> {
'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}', 'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}',
'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}', 'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}',
'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}', 'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}',
'Received copies: ${widget.receivedCopies}',
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}', 'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}', 'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}', 'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
@@ -777,6 +849,12 @@ class _MessageBubbleState extends State<MessageBubble> {
receptionDetails!.postTransmitDelayMs!, receptionDetails!.postTransmitDelayMs!,
), ),
), ),
if (widget.receivedCopies > 1)
_detailRow(
context,
label: 'Received copies',
value: '${widget.receivedCopies}',
),
if (widget.message.suggestedTimeoutMs != null) if (widget.message.suggestedTimeoutMs != null)
_detailRow( _detailRow(
context, context,
@@ -1721,6 +1799,7 @@ class _MessageBubbleState extends State<MessageBubble> {
receptionDetails?.rssiDbm ?? receptionDetails?.rssiDbm ??
matchedRxLog?.logRxDataInfo?.rssiDbm ?? matchedRxLog?.logRxDataInfo?.rssiDbm ??
message.lastEchoRssiDbm; message.lastEchoRssiDbm;
final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id);
// Look up contact information for rich display name // Look up contact information for rich display name
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
@@ -2004,9 +2083,11 @@ class _MessageBubbleState extends State<MessageBubble> {
Expanded( Expanded(
child: Text( child: Text(
displayName, displayName,
style: Theme.of(context).textTheme.labelMedium style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
height: 1.1,
color: isOwnMessage color: isOwnMessage
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: null, : null,
@@ -2229,7 +2310,10 @@ class _MessageBubbleState extends State<MessageBubble> {
); );
if (drawing == null) { if (drawing == null) {
return Text(message.text, style: baseBodyStyle); return _buildMessageTextContent(
message.text,
baseBodyStyle,
);
} }
final String drawingTypeLabel; final String drawingTypeLabel;
@@ -2315,7 +2399,7 @@ class _MessageBubbleState extends State<MessageBubble> {
) )
// Regular message content // Regular message content
else if (!message.isDrawing || widget.isCompact) else if (!message.isDrawing || widget.isCompact)
Text(message.text, style: baseBodyStyle), _buildMessageTextContent(message.text, baseBodyStyle),
if (!widget.isCompact && if (!widget.isCompact &&
!isSarMarker && !isSarMarker &&
@@ -2445,7 +2529,7 @@ class _MessageBubbleState extends State<MessageBubble> {
switch (recipient.deliveryStatus) { switch (recipient.deliveryStatus) {
case MessageDeliveryStatus.delivered: case MessageDeliveryStatus.delivered:
statusColor = Colors.green; statusColor = Colors.green;
statusIcon = Icons.check_circle; statusIcon = Icons.done_all;
statusText = statusText =
recipient.roundTripTimeMs != null recipient.roundTripTimeMs != null
? '${recipient.roundTripTimeMs}ms' ? '${recipient.roundTripTimeMs}ms'
@@ -2453,6 +2537,15 @@ class _MessageBubbleState extends State<MessageBubble> {
context, context,
)!.delivered; )!.delivered;
break; break;
case MessageDeliveryStatus.sent:
statusColor = Theme.of(
context,
).colorScheme.onSurfaceVariant;
statusIcon = Icons.done;
statusText = AppLocalizations.of(
context,
)!.sent;
break;
case MessageDeliveryStatus.failed: case MessageDeliveryStatus.failed:
statusColor = Colors.red; statusColor = Colors.red;
statusIcon = Icons.cancel; statusIcon = Icons.cancel;
@@ -2461,7 +2554,6 @@ class _MessageBubbleState extends State<MessageBubble> {
)!.failed; )!.failed;
break; break;
case MessageDeliveryStatus.sending: case MessageDeliveryStatus.sending:
case MessageDeliveryStatus.sent:
default: default:
statusColor = Colors.orange; statusColor = Colors.orange;
statusIcon = Icons.schedule; statusIcon = Icons.schedule;
@@ -2656,6 +2748,7 @@ class _MessageBubbleState extends State<MessageBubble> {
context, context,
message: message, message: message,
isSarMarker: isSarMarker, isSarMarker: isSarMarker,
routeMetadata: routeMetadata,
), ),
], ],
); );

View File

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

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/message_route_metadata.dart';
import '../../models/path_selection.dart'; import '../../models/path_selection.dart';
import '../../models/message_reception_details.dart'; import '../../models/message_reception_details.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
@@ -209,7 +210,7 @@ Widget buildSentDirectSignalStatus(
_techChip( _techChip(
context, context,
icon: Icons.alt_route, icon: Icons.alt_route,
label: hopDisplayLabel(message), label: hopDisplayLabelForMessage(message, routeMetadata),
color: Colors.indigo, color: Colors.indigo,
), ),
_techChip( _techChip(
@@ -294,6 +295,17 @@ String hopDisplayLabel(Message message) {
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}'; 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( Widget _techChip(
BuildContext context, { BuildContext context, {
required IconData icon, required IconData icon,

View File

@@ -10,14 +10,31 @@ import '../../models/ble_packet_log.dart';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../providers/connection_provider.dart'; import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../services/mesh_map_nodes_service.dart'; import '../../services/mesh_map_nodes_service.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
import '../../utils/log_rx_route_decoder.dart'; import '../../utils/log_rx_route_decoder.dart';
import '../../utils/trace_node_resolver.dart';
class MessageTraceSheet extends StatefulWidget { class MessageTraceSheet extends StatefulWidget {
final Message message; final Message? message;
final List<int>? packetPathOverride;
final String? descriptionOverride;
final String? noRelayMatchTextOverride;
const MessageTraceSheet({super.key, required this.message}); const MessageTraceSheet({super.key, required this.message})
: assert(message != null),
packetPathOverride = null,
descriptionOverride = null,
noRelayMatchTextOverride = null;
const MessageTraceSheet.packetPath({
super.key,
required List<int> packetPath,
this.descriptionOverride,
this.noRelayMatchTextOverride,
}) : message = null,
packetPathOverride = packetPath;
@override @override
State<MessageTraceSheet> createState() => _MessageTraceSheetState(); State<MessageTraceSheet> createState() => _MessageTraceSheetState();
@@ -25,6 +42,7 @@ class MessageTraceSheet extends StatefulWidget {
class _MessageTraceSheetState extends State<MessageTraceSheet> { class _MessageTraceSheetState extends State<MessageTraceSheet> {
late final Future<_TraceResult> _future; late final Future<_TraceResult> _future;
_TraceResult? _traceOverride;
@override @override
void initState() { void initState() {
@@ -35,20 +53,34 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async { Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>(); final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>(); final contactsProvider = context.read<ContactsProvider>();
final messagesProvider = context.read<MessagesProvider>();
final preferredHashSize = await RouteHashPreferences.getHashSize(); final preferredHashSize = await RouteHashPreferences.getHashSize();
final packetPath = _extractPathFromPacketLogs( List<int>? packetPath = widget.packetPathOverride;
String? senderPrefix;
String? recipientPrefix;
if (widget.message case final message?) {
final storedPath = messagesProvider
.getMessageReceptionDetails(message.id)
?.pathBytes;
packetPath = (storedPath != null && storedPath.isNotEmpty)
? storedPath
: _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs, logs: connectionProvider.bleService.packetLogs,
message: widget.message, message: message,
); );
final senderPrefix = _toPrefixHex(widget.message.senderPublicKeyPrefix); senderPrefix = _toPrefixHex(message.senderPublicKeyPrefix);
final recipientPrefix = widget.message.recipientPublicKey != null recipientPrefix = message.recipientPublicKey != null
? _toPrefixHex(widget.message.recipientPublicKey) ? _toPrefixHex(message.recipientPublicKey)
: _toPrefixHex(connectionProvider.deviceInfo.publicKey); : _toPrefixHex(connectionProvider.deviceInfo.publicKey);
}
final localNodes = _localNodesFromContacts(contactsProvider); final localNodes = _localNodesFromContacts(contactsProvider);
final localPublicKeys = localNodes.map((node) => node.publicKey).toSet();
var trace = _buildTraceResult( var trace = _buildTraceResult(
nodes: localNodes, nodes: localNodes,
localPublicKeys: localPublicKeys,
packetPath: packetPath, packetPath: packetPath,
preferredHashSize: preferredHashSize, preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
@@ -56,7 +88,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
if (_isCompleteTrace( if (_isCompleteTrace(
trace, trace,
expectedRelayCount: math.max(0, widget.message.pathLen), expectedRelayCount: widget.message == null
? 0
: math.max(0, widget.message!.pathLen),
)) { )) {
return trace; return trace;
} }
@@ -72,6 +106,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
trace = _buildTraceResult( trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes), nodes: _mergeNodes(localNodes, remoteNodes),
localPublicKeys: localPublicKeys,
packetPath: packetPath, packetPath: packetPath,
preferredHashSize: preferredHashSize, preferredHashSize: preferredHashSize,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
@@ -104,11 +139,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
} }
final trace = snapshot.data!; final trace = _traceOverride ?? snapshot.data!;
final routeEntries = _displayRouteEntries(trace); final routeEntries = _displayRouteEntries(trace);
final concretePathNodes = routeEntries final concretePathNodes = routeEntries
.where((entry) => entry.node != null) .where((entry) => entry.resolved.node != null)
.map((entry) => entry.node!) .map((entry) => entry.resolved.node!)
.toList(); .toList();
final mapPoints = concretePathNodes final mapPoints = concretePathNodes
.map((n) => LatLng(n.latitude, n.longitude)) .map((n) => LatLng(n.latitude, n.longitude))
@@ -142,9 +177,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
trace.mode == TraceMode.packetPath widget.descriptionOverride ??
? 'Route from packet path bytes' (trace.mode == TraceMode.packetPath
: 'Route inferred from hop count (${widget.message.pathLen})', ? 'Relay path from packet path bytes'
: 'Relay path inferred from hop count (${widget.message!.pathLen})'),
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
), ),
@@ -211,14 +247,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
child: CircleAvatar( child: CircleAvatar(
radius: 16, radius: 16,
backgroundColor: backgroundColor:
entry.key == 0 Colors.blue,
? Colors.green
: (entry.key ==
concretePathNodes
.length -
1
? Colors.red
: Colors.blue),
child: Text( child: Text(
'${entry.key + 1}', '${entry.key + 1}',
style: const TextStyle( style: const TextStyle(
@@ -248,29 +277,35 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
'Route', 'Relay path',
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
), ),
if (routeEntries.isEmpty) if (routeEntries.isEmpty)
const Padding( Padding(
padding: EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
vertical: 8, vertical: 8,
), ),
child: Text( child: Text(
widget.noRelayMatchTextOverride ??
'No named nodes could be matched for this trace.', 'No named nodes could be matched for this trace.',
), ),
), ),
...routeEntries.asMap().entries.map( ...routeEntries.asMap().entries.map(
(entry) => ListTile( (entry) => ListTile(
onTap: entry.value.resolved.canCycle
? () => setState(() {
final baseTrace =
_traceOverride ?? snapshot.data!;
_traceOverride = baseTrace.cycleEntry(
entry.value.target,
);
})
: null,
leading: CircleAvatar( leading: CircleAvatar(
radius: 14, radius: 14,
backgroundColor: entry.key == 0 backgroundColor: Colors.blue,
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
child: Text( child: Text(
'${entry.key + 1}', '${entry.key + 1}',
style: const TextStyle( style: const TextStyle(
@@ -282,8 +317,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
), ),
title: Text(entry.value.label), title: Text(entry.value.label),
subtitle: Text( subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}', 'Relay${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}${entry.value.resolved.cycleSummary == null ? '' : '${entry.value.resolved.cycleSummary}'}',
), ),
trailing: entry.value.resolved.canCycle
? const Icon(Icons.sync_alt)
: null,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -326,10 +364,16 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
List<MeshMapNode> _relayNodes(_TraceResult trace) { 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 (concrete.isEmpty) return const [];
if (trace.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
if (widget.message == null) {
return concrete;
}
if (concrete.length <= 1) return const []; if (concrete.length <= 1) return const [];
return concrete.sublist(1); return concrete.sublist(1);
} }
@@ -339,55 +383,37 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) { List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
final pathNodes = trace.matchedPathNodes.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.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) { return trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key].toUpperCase(); final hashHex = trace.pathHashes[entry.key].toUpperCase();
return _RouteDisplayEntry( return _RouteDisplayEntry(
node: entry.value, resolved: entry.value,
label: entry.value?.name ?? 'Unknown', label: entry.value.node?.name ?? 'Unknown',
keyLabel: entry.value != null keyLabel: entry.value.node != null
? _prefixKeyLabel(entry.value!.publicKey) ? _prefixKeyLabel(entry.value.node!.publicKey)
: hashHex, : hashHex,
matchSummary: entry.value.matchSummary,
target: _RouteEntryTarget.pathNode(entry.key),
); );
}).toList(); }).toList();
final lastKey = pathNodes.last.publicKey;
return [
...entries,
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
];
} }
final firstKey = pathNodes.first.publicKey; return trace.matchedPathNodes
final lastKey = pathNodes.last.publicKey; .asMap()
return [ .entries
if (trace.sender != null && trace.sender!.publicKey != firstKey) .where((entry) => entry.value.node != null)
_RouteDisplayEntry.fromNode(trace.sender!), .map(
...pathNodes.map(_RouteDisplayEntry.fromNode), (entry) => _RouteDisplayEntry.fromResolved(
if (trace.recipient != null && trace.recipient!.publicKey != lastKey) entry.value,
_RouteDisplayEntry.fromNode(trace.recipient!), target: _RouteEntryTarget.pathNode(entry.key),
]; ),
)
.toList();
} }
String _prefixKeyLabel(String publicKey) => String _prefixKeyLabel(String publicKey) =>
publicKey.substring(0, math.min(12, publicKey.length)); publicKey.substring(0, math.min(12, publicKey.length));
String _routeRoleLabel(int index, int total) {
if (index == 0) return 'Sender';
if (index == total - 1) return 'Recipient';
return 'Relay';
}
String? _toPrefixHex(List<int>? key) { String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null; if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6; final take = key.length < 6 ? key.length : 6;
@@ -398,14 +424,6 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
.toLowerCase(); .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) { List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
return contactsProvider.contactsWithLocation return contactsProvider.contactsWithLocation
.map((contact) { .map((contact) {
@@ -440,13 +458,28 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
_TraceResult _buildTraceResult({ _TraceResult _buildTraceResult({
required List<MeshMapNode> nodes, required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<int>? packetPath, required List<int>? packetPath,
required int preferredHashSize, required int preferredHashSize,
required String? senderPrefix, required String? senderPrefix,
required String? recipientPrefix, required String? recipientPrefix,
}) { }) {
final senderNode = _bestNodeForPrefix(nodes, senderPrefix); final senderNode = TraceNodeResolver.resolveBest(
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix); 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) { if (packetPath != null && packetPath.isNotEmpty) {
final hashSize = LogRxRouteDecoder.inferHashSize( final hashSize = LogRxRouteDecoder.inferHashSize(
@@ -459,29 +492,43 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
); );
final matched = _matchNodesFromPathHashes( final matched = _matchNodesFromPathHashes(
nodes: nodes, nodes: nodes,
localPublicKeys: localPublicKeys,
pathHashes: hopHashes, pathHashes: hopHashes,
senderPrefix: senderPrefix, senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix, recipientPrefix: recipientPrefix,
senderLatLng: senderLatLng,
recipientLatLng: recipientLatLng,
);
final alignedMatched = TraceNodeResolver.alignPathSelections(
nodes: matched,
startNode: senderNode.node,
endNode: recipientNode.node,
); );
return _TraceResult( return _TraceResult(
mode: TraceMode.packetPath, mode: TraceMode.packetPath,
sender: senderNode, sender: senderNode,
recipient: recipientNode, recipient: recipientNode,
pathHashes: hopHashes, pathHashes: hopHashes,
matchedPathNodes: matched, matchedPathNodes: alignedMatched,
); );
} }
final inferred = _inferRelaysFromHopCount( final inferred = _inferRelaysFromHopCount(
nodes: nodes, nodes: nodes,
sender: senderNode, sender: senderNode.node,
recipient: recipientNode, recipient: recipientNode.node,
relayCount: math.max(0, widget.message.pathLen), relayCount: math.max(0, widget.message!.pathLen),
); );
final matchedPathNodes = <MeshMapNode?>[ final matchedPathNodes = <ResolvedTraceNode>[
if (senderNode != null) senderNode, if (senderNode.node != null) senderNode,
...inferred, ...inferred.map(
if (recipientNode != null) recipientNode, (node) => ResolvedTraceNode(
candidates: [node],
matchCount: 1,
usedOnlineFallback: false,
),
),
if (recipientNode.node != null) recipientNode,
]; ];
return _TraceResult( return _TraceResult(
@@ -494,16 +541,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) { 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; return false;
} }
if (trace.mode == TraceMode.packetPath) { if (trace.mode == TraceMode.packetPath) {
return trace.matchedPathNodes.length == trace.pathHashes.length && return trace.matchedPathNodes.length == trace.pathHashes.length &&
trace.matchedPathNodes.every((node) => node != null); trace.matchedPathNodes.every((node) => node.node != null);
} }
final concreteCount = trace.matchedPathNodes final concreteCount = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>() .whereType<MeshMapNode>()
.length; .length;
return concreteCount >= expectedRelayCount + 2; return concreteCount >= expectedRelayCount + 2;
@@ -544,33 +592,30 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
return decoded.pathBytes; return decoded.pathBytes;
} }
List<MeshMapNode?> _matchNodesFromPathHashes({ List<ResolvedTraceNode> _matchNodesFromPathHashes({
required List<MeshMapNode> nodes, required List<MeshMapNode> nodes,
required Set<String> localPublicKeys,
required List<String> pathHashes, required List<String> pathHashes,
required String? senderPrefix, required String? senderPrefix,
required String? recipientPrefix, required String? recipientPrefix,
required LatLng? senderLatLng,
required LatLng? recipientLatLng,
}) { }) {
final result = <MeshMapNode?>[]; final result = <ResolvedTraceNode>[];
for (var i = 0; i < pathHashes.length; i++) { for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toLowerCase(); final hashHex = pathHashes[i].toLowerCase();
final candidates = nodes result.add(
.where((n) => n.publicKey.startsWith(hashHex)) TraceNodeResolver.resolveBest(
.toList(); nodes: nodes,
if (candidates.isEmpty) { localPublicKeys: localPublicKeys,
result.add(null); prefixHex: hashHex,
continue; preferredPrefix: i == 0
} ? senderPrefix
: (i == pathHashes.length - 1 ? recipientPrefix : null),
List<MeshMapNode> filtered = candidates; referenceA: senderLatLng,
if (i == 0 && senderPrefix != null) { referenceB: recipientLatLng,
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);
} }
return result; return result;
} }
@@ -639,10 +684,10 @@ enum TraceMode { packetPath, hopCountInference }
class _TraceResult { class _TraceResult {
final TraceMode mode; final TraceMode mode;
final MeshMapNode? sender; final ResolvedTraceNode sender;
final MeshMapNode? recipient; final ResolvedTraceNode recipient;
final List<String> pathHashes; final List<String> pathHashes;
final List<MeshMapNode?> matchedPathNodes; final List<ResolvedTraceNode> matchedPathNodes;
const _TraceResult({ const _TraceResult({
required this.mode, required this.mode,
@@ -651,27 +696,66 @@ class _TraceResult {
required this.pathHashes, required this.pathHashes,
required this.matchedPathNodes, required this.matchedPathNodes,
}); });
_TraceResult cycleEntry(_RouteEntryTarget target) {
switch (target.kind) {
case _RouteEntryKind.pathNode:
final updated = matchedPathNodes.toList();
updated[target.index] = updated[target.index].cycle();
return _TraceResult(
mode: mode,
sender: sender,
recipient: recipient,
pathHashes: pathHashes,
matchedPathNodes: updated,
);
}
}
} }
class _RouteDisplayEntry { class _RouteDisplayEntry {
final MeshMapNode? node; final ResolvedTraceNode resolved;
final String label; final String label;
final String? keyLabel; final String? keyLabel;
final String? matchSummary;
final _RouteEntryTarget target;
const _RouteDisplayEntry({ const _RouteDisplayEntry({
required this.node, required this.resolved,
required this.label, required this.label,
required this.keyLabel, required this.keyLabel,
required this.matchSummary,
required this.target,
}); });
factory _RouteDisplayEntry.fromNode(MeshMapNode node) { MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(
ResolvedTraceNode resolved, {
required _RouteEntryTarget target,
}) {
final node = resolved.node!;
return _RouteDisplayEntry( return _RouteDisplayEntry(
node: node, resolved: resolved,
label: node.name, label: node.name,
keyLabel: node.publicKey.substring( keyLabel: node.publicKey.substring(
0, 0,
math.min(12, node.publicKey.length), math.min(12, node.publicKey.length),
), ),
matchSummary: resolved.matchSummary,
target: target,
); );
} }
} }
enum _RouteEntryKind { pathNode }
class _RouteEntryTarget {
final _RouteEntryKind kind;
final int index;
const _RouteEntryTarget._(this.kind, [this.index = 0]);
const _RouteEntryTarget.pathNode(int index)
: this._(_RouteEntryKind.pathNode, index);
}

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