Compare commits

...

43 Commits

Author SHA1 Message Date
Janez T
5e404944e9 Investigate REQ PATH usage 2026-03-12 20:56:37 +01:00
Janez T
53daf2c9a7 Add packet filter stats and hex 2026-03-12 20:54:04 +01:00
Janez T
c36d63a0ad Add Flutter live traffic monitor 2026-03-12 20:19:24 +01:00
Janez T
69ec2dfdb5 Add live mesh traffic view 2026-03-12 20:12:25 +01:00
Janez T
d2e6b692f3 Fix voice quality regression 2026-03-12 19:38:41 +01:00
Janez T
baa1aa6edd Update contacts filter UI 2026-03-12 14:46:36 +01:00
Janez T
38cb3dc031 Fix retry flow and contact filters 2026-03-12 14:41:12 +01:00
Janez T
abb73a4c65 Fix retry flow and contact filters 2026-03-12 14:39:44 +01:00
Janez T
2d1e6c0ca4 Fix tracing path and display issues 2026-03-12 14:08:00 +01:00
Janez T
b38a8c55d6 Switch lpcnet plugin to git 2026-03-11 21:20:53 +01:00
Janez T
ca872f2049 Add LPCNet voice mode support 2026-03-11 21:16:08 +01:00
Janez T
1b6ff59af3 Add LPCNet voice mode option 2026-03-11 20:47:17 +01:00
Janez T
e31bfac2ac Retain rx path for adverts 2026-03-11 20:21:17 +01:00
Janez T
99bb446ee9 Update iOS Runner project version 2026-03-11 16:31:53 +01:00
Janez T
088f3d37ef Update Portuguese wizard text 2026-03-11 16:07:38 +01:00
Janez T
7f200e5334 Update translations for all locales 2026-03-11 16:07:09 +01:00
Janez T
ae16417886 Resolve localization and name bugs 2026-03-11 15:54:00 +01:00
Janez T
467371946d Add channel slot detection fix 2026-03-11 15:30:31 +01:00
Janez T
2d23328ad0 Fix empty channel slot handling 2026-03-11 15:10:57 +01:00
Janez T
0dfb84bf4e Fix onboarding layout issues 2026-03-11 15:08:04 +01:00
Janez T
193a193e8a Ensure constant iOS location updates 2026-03-10 19:56:24 +01:00
Janez T
c1d1ec7d0c Enable iOS background GPS updates 2026-03-10 19:50:02 +01:00
Janez T
b415b63b4b Add GPX import export for trails 2026-03-10 13:52:37 +01:00
Janez T
818b9dbfb2 Review MeshCore payload status 2026-03-10 11:05:54 +01:00
Janez T
8085b89c07 Add repeaters map menu entry 2026-03-10 10:38:59 +01:00
Janez T
86771a3c55 Add fullscreen repeater map 2026-03-10 10:25:43 +01:00
Janez T
4b668ac2e9 Fix channel list layout behavior 2026-03-10 09:46:38 +01:00
Janez T
9deb563c05 Align and compact channel cards 2026-03-10 09:45:16 +01:00
Janez T
15ac3e2798 Align channel avatars and compact 2026-03-10 09:13:24 +01:00
Janez T
97addf7ba6 Hide last seen and tweak channels 2026-03-10 09:06:30 +01:00
Janez T
025bd737e7 Group contacts by last seen 2026-03-10 08:54:55 +01:00
Janez T
07b675dec6 Align repeater tile behavior 2026-03-10 08:48:04 +01:00
Janez T
d675ce18dd Use LogRxRouteDecoder in AppProvider 2026-03-10 08:43:58 +01:00
Janez T
a8e31bb2e8 Investigate DEVICE_QUERY timeout 2026-03-09 19:59:29 +01:00
Janez T
3cd96ba961 Fix scan command timeout 2026-03-09 19:53:36 +01:00
Janez T
a98fc6314e Analyze BLE MeshCore scan log 2026-03-09 19:45:17 +01:00
Janez T
ae1ea71f58 Analyze MeshCore BLE scan logs 2026-03-09 19:39:14 +01:00
Janez T
4dff79cd7b Update client ref and deps 2026-03-09 19:33:42 +01:00
Janez T
cbcb329c70 Fix NotificationService platform 2026-03-09 19:23:35 +01:00
Janez T
7abf96abf2 Point web build to meshcore-sar 2026-03-09 19:17:26 +01:00
Janez T
13bf74178c Update iOS TestFlight link 2026-03-09 16:31:42 +01:00
Janez T
66a00c9569 Remove sender name from notification 2026-03-08 21:12:08 +01:00
Janez T
b60fa008eb Fix ios notifications 2026-03-08 21:07:59 +01:00
109 changed files with 14732 additions and 1991 deletions

View File

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

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

View File

@@ -16,7 +16,7 @@
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app. MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication. It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp `iOS TestFlight:` https://testflight.apple.com/join/nCZYMPPz
## Highlights ## Highlights

View File

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

View File

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

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>106</string> <string>112</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.00029">
</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.339599">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="95.467725"> <testcase classname="fastlane.lanes" name="2: build_app" time="116.294416">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="240.60994"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="441.763052">
<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

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

View File

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

View File

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

View File

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

@@ -29,6 +29,7 @@ import '../utils/image_message_parser.dart';
import '../utils/media_swarm_protocol.dart'; import '../utils/media_swarm_protocol.dart';
import '../utils/message_airtime_estimator.dart'; import '../utils/message_airtime_estimator.dart';
import '../utils/fast_gps_packet.dart'; import '../utils/fast_gps_packet.dart';
import '../utils/log_rx_route_decoder.dart';
class _DirectMessageRouteSession { class _DirectMessageRouteSession {
final PathSelection currentSelection; final PathSelection currentSelection;
@@ -90,6 +91,12 @@ class AppProvider with ChangeNotifier {
bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled; bool get isVoiceCompressorEnabled => _isVoiceCompressorEnabled;
bool _isVoiceLimiterEnabled = true; bool _isVoiceLimiterEnabled = true;
bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled; bool get isVoiceLimiterEnabled => _isVoiceLimiterEnabled;
bool _isVoiceAutoGainEnabled = false;
bool get isVoiceAutoGainEnabled => _isVoiceAutoGainEnabled;
bool _isVoiceEchoCancellationEnabled = false;
bool get isVoiceEchoCancellationEnabled => _isVoiceEchoCancellationEnabled;
bool _isVoiceNoiseSuppressionEnabled = false;
bool get isVoiceNoiseSuppressionEnabled => _isVoiceNoiseSuppressionEnabled;
double _messageFontScale = 1.0; double _messageFontScale = 1.0;
double get messageFontScale => _messageFontScale; double get messageFontScale => _messageFontScale;
bool _autoAddDiscoveredContacts = false; bool _autoAddDiscoveredContacts = false;
@@ -127,6 +134,9 @@ class AppProvider with ChangeNotifier {
Timer? _packetCaptureFlushTimer; Timer? _packetCaptureFlushTimer;
String? _lastPersistedPacketSignature; String? _lastPersistedPacketSignature;
bool _isPersistingPacketCapture = false; bool _isPersistingPacketCapture = false;
bool _wasDeviceConnected = false;
bool _hasCompletedConnectionBootstrap = false;
bool _isReconnectSyncInProgress = false;
AppProvider({ AppProvider({
required this.connectionProvider, required this.connectionProvider,
@@ -138,6 +148,9 @@ class AppProvider with ChangeNotifier {
required this.imageProvider, required this.imageProvider,
}) { }) {
_setupCallbacks(); _setupCallbacks();
connectionProvider.canStartAutomaticMessageSyncCallback =
_canStartAutomaticMessageSync;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
_initializeLocationTracking(); _initializeLocationTracking();
_loadMapEnabled(); _loadMapEnabled();
_loadContactsEnabled(); _loadContactsEnabled();
@@ -146,6 +159,9 @@ class AppProvider with ChangeNotifier {
_loadVoiceBandPassFilterEnabled(); _loadVoiceBandPassFilterEnabled();
_loadVoiceCompressorEnabled(); _loadVoiceCompressorEnabled();
_loadVoiceLimiterEnabled(); _loadVoiceLimiterEnabled();
_loadVoiceAutoGainEnabled();
_loadVoiceEchoCancellationEnabled();
_loadVoiceNoiseSuppressionEnabled();
_loadMessageFontScale(); _loadMessageFontScale();
_loadAutoAddDiscoveredContacts(); _loadAutoAddDiscoveredContacts();
_loadMessagingRouteSettings(); _loadMessagingRouteSettings();
@@ -434,6 +450,72 @@ class AppProvider with ChangeNotifier {
} }
} }
Future<void> _loadVoiceAutoGainEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceAutoGainEnabled =
prefs.getBool('voice_auto_gain_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice auto gain setting: $e');
}
}
Future<void> toggleVoiceAutoGainEnabled(bool enabled) async {
try {
_isVoiceAutoGainEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_auto_gain_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice auto gain setting: $e');
}
}
Future<void> _loadVoiceEchoCancellationEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceEchoCancellationEnabled =
prefs.getBool('voice_echo_cancellation_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice echo cancellation setting: $e');
}
}
Future<void> toggleVoiceEchoCancellationEnabled(bool enabled) async {
try {
_isVoiceEchoCancellationEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_echo_cancellation_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice echo cancellation setting: $e');
}
}
Future<void> _loadVoiceNoiseSuppressionEnabled() async {
try {
final prefs = await SharedPreferences.getInstance();
_isVoiceNoiseSuppressionEnabled =
prefs.getBool('voice_noise_suppression_enabled') ?? false;
notifyListeners();
} catch (e) {
debugPrint('Error loading voice noise suppression setting: $e');
}
}
Future<void> toggleVoiceNoiseSuppressionEnabled(bool enabled) async {
try {
_isVoiceNoiseSuppressionEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('voice_noise_suppression_enabled', enabled);
notifyListeners();
} catch (e) {
debugPrint('Error saving voice noise suppression setting: $e');
}
}
Future<void> _loadMessageFontScale() async { Future<void> _loadMessageFontScale() async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -569,8 +651,20 @@ class AppProvider with ChangeNotifier {
connectionProvider.addListener(_handleConnectionStateChange); connectionProvider.addListener(_handleConnectionStateChange);
messagesProvider.resolveContactNameCallback = messagesProvider.resolveContactNameCallback =
_resolveContactNameForNotification; _resolveContactNameForNotification;
messagesProvider.resolveChannelNameCallback = messagesProvider.resolveChannelNameCallback = (channelIdx) {
channelsProvider.getChannelDisplayName; if (channelIdx == 0) {
return 'Public';
}
for (final channel in contactsProvider.channels) {
if (channel.publicKey.length > 1 &&
channel.publicKey[1] == channelIdx) {
return channel.displayName;
}
}
return channelsProvider.getChannelDisplayName(channelIdx);
};
voiceProvider.sendRawPacketCallback = voiceProvider.sendRawPacketCallback =
({ ({
@@ -758,7 +852,6 @@ class AppProvider with ChangeNotifier {
final receivedPathBytes = receptionDetailsSnapshot?.pathBytes; final receivedPathBytes = receptionDetailsSnapshot?.pathBytes;
if (senderContact != null && if (senderContact != null &&
enrichedMessage.isChannelMessage && enrichedMessage.isChannelMessage &&
(enrichedMessage.channelIdx ?? 0) == 0 &&
receivedPathBytes != null && receivedPathBytes != null &&
receivedPathBytes.isNotEmpty) { receivedPathBytes.isNotEmpty) {
unawaited( unawaited(
@@ -1163,6 +1256,7 @@ class AppProvider with ChangeNotifier {
debugPrint( debugPrint(
'📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...', '📡 [AppProvider] Advertisement received: ${publicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}...',
); );
unawaited(_retainAdvertRxPath(publicKey));
// Check if this is an existing contact that might have updated location // Check if this is an existing contact that might have updated location
final contact = contactsProvider.findContactByKey(publicKey); final contact = contactsProvider.findContactByKey(publicKey);
if (contact != null) { if (contact != null) {
@@ -1276,6 +1370,9 @@ class AppProvider with ChangeNotifier {
retryAttempt: retryAttempt, retryAttempt: retryAttempt,
); );
}; };
connectionProvider.resolveContactForDmCallback = (contactPublicKey) {
return contactsProvider.findContactByKey(contactPublicKey);
};
messagesProvider.onFinalRouterFallbackCallback = messagesProvider.onFinalRouterFallbackCallback =
({required messageId, required contact, required message}) async { ({required messageId, required contact, required message}) async {
return _sendWithFinalNearestRouterFallback( return _sendWithFinalNearestRouterFallback(
@@ -1304,6 +1401,9 @@ class AppProvider with ChangeNotifier {
roundTripTimeMs: roundTripTimeMs, roundTripTimeMs: roundTripTimeMs,
); );
}; };
messagesProvider.onManualRetryPreparedCallback = (messageId) {
_directMessageRouteSessions.remove(messageId);
};
} }
Future<Contact> _prepareDirectMessageSend({ Future<Contact> _prepareDirectMessageSend({
@@ -1566,6 +1666,38 @@ class AppProvider with ChangeNotifier {
); );
} }
Future<void> _retainAdvertRxPath(Uint8List publicKey) async {
final decoded = _findBestMatchingAdvertRxRoute(publicKey);
if (decoded == null || decoded.pathBytes.isEmpty) {
return;
}
final reversedPathBytes = LogRxRouteDecoder.reverseHopBytes(
decoded.pathBytes,
hashSize: decoded.hashSize,
);
final reversedHopHashes = LogRxRouteDecoder.splitHopHashes(
reversedPathBytes,
hashSize: decoded.hashSize,
);
final parsedRoute = ContactRouteCodec.parse(
reversedHopHashes.join(','),
expectedHashSize: decoded.hashSize,
);
contactsProvider.retainReceivedRoute(
publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
await _pathHistoryService.recordReceivedBytePath(
publicKey.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(),
decoded.pathBytes,
decoded.hashSize,
);
}
int _inferReceivedPathHashSize( int _inferReceivedPathHashSize(
List<int> pathBytes, { List<int> pathBytes, {
required int preferredHashSize, required int preferredHashSize,
@@ -1587,6 +1719,8 @@ class AppProvider with ChangeNotifier {
if (!connectionProvider.deviceInfo.isConnected) return; if (!connectionProvider.deviceInfo.isConnected) return;
try { try {
_isReconnectSyncInProgress = true;
_hasCompletedConnectionBootstrap = false;
// Initialize contacts provider with device public key to exclude self // Initialize contacts provider with device public key to exclude self
// If already initialized (from early load), this will just filter out self-contact // If already initialized (from early load), this will just filter out self-contact
// This must happen before getContacts to ensure proper filtering // This must happen before getContacts to ensure proper filtering
@@ -1638,7 +1772,9 @@ class AppProvider with ChangeNotifier {
debugPrint( debugPrint(
'🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)', '🔄 [AppProvider] Performing initial message sync (fallback for missed pushes)',
); );
final initialMessageCount = await connectionProvider.syncAllMessages(); final initialMessageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint( debugPrint(
'📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)', '📥 [AppProvider] Initial sync retrieved $initialMessageCount message(s)',
); );
@@ -1658,9 +1794,51 @@ class AppProvider with ChangeNotifier {
); );
messagesProvider.syncDrawingsWithProvider(drawingProvider); messagesProvider.syncDrawingsWithProvider(drawingProvider);
_hasCompletedConnectionBootstrap = true;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
await _flushDeferredAutomaticMessageSync();
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Initialization error: $e'); debugPrint('Initialization error: $e');
} finally {
_isReconnectSyncInProgress = false;
}
}
Future<void> _syncAfterReconnect({bool started = false}) async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
if (!started) {
if (_isReconnectSyncInProgress) {
return;
}
_isReconnectSyncInProgress = true;
}
try {
debugPrint(
'🔄 [AppProvider] Device reconnected - syncing contacts and missed messages',
);
await contactsProvider.initialize(
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
await connectionProvider.getContacts();
final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint(
'📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)',
);
} catch (e) {
debugPrint('❌ [AppProvider] Reconnect sync error: $e');
} finally {
_hasCompletedConnectionBootstrap =
connectionProvider.deviceInfo.isConnected;
_isReconnectSyncInProgress = false;
await _flushDeferredAutomaticMessageSync();
} }
} }
@@ -1973,14 +2151,13 @@ class AppProvider with ChangeNotifier {
if (requester == null || if (requester == null ||
!requester.routeHasPath || !requester.routeHasPath ||
requester.routeHopCount > _maxDirectPayloadHops || requester.routeHopCount > _maxDirectPayloadHops ||
!requester.routeSupportsLegacyRawTransport ||
requester.outPath.isEmpty) { requester.outPath.isEmpty) {
return; return;
} }
unawaited( unawaited(
connectionProvider.sendRawVoicePacket( connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath, contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen, contactPathLen: requester.routeEncodedPathLen,
payload: availability.encodeBinary(), payload: availability.encodeBinary(),
), ),
); );
@@ -2050,7 +2227,7 @@ class AppProvider with ChangeNotifier {
for (final peer in peers) { for (final peer in peers) {
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: peer.outPath, contactPath: peer.outPath,
contactPathLen: peer.routeSignedPathLen, contactPathLen: peer.routeEncodedPathLen,
payload: request.encodeBinary(), payload: request.encodeBinary(),
); );
} }
@@ -2073,7 +2250,6 @@ class AppProvider with ChangeNotifier {
if (responder == null || if (responder == null ||
!responder.routeHasPath || !responder.routeHasPath ||
responder.routeHopCount > _maxDirectPayloadHops || responder.routeHopCount > _maxDirectPayloadHops ||
!responder.routeSupportsLegacyRawTransport ||
responder.outPath.isEmpty) { responder.outPath.isEmpty) {
continue; continue;
} }
@@ -2165,7 +2341,7 @@ class AppProvider with ChangeNotifier {
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath, contactPath: target.outPath,
contactPathLen: target.routeSignedPathLen, contactPathLen: target.routeEncodedPathLen,
payload: payload, payload: payload,
); );
return true; return true;
@@ -2406,9 +2582,6 @@ class AppProvider with ChangeNotifier {
if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) { if (!target.routeHasPath || target.routeHopCount > _maxDirectPayloadHops) {
return false; return false;
} }
if (!target.routeSupportsLegacyRawTransport) {
return false;
}
if (target.outPath.isEmpty) { if (target.outPath.isEmpty) {
return false; return false;
} }
@@ -2441,7 +2614,7 @@ class AppProvider with ChangeNotifier {
); );
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: target.outPath, contactPath: target.outPath,
contactPathLen: target.routeSignedPathLen, contactPathLen: target.routeEncodedPathLen,
payload: RawRouteProbeRequest( payload: RawRouteProbeRequest(
nonce: nonce, nonce: nonce,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
@@ -2469,7 +2642,7 @@ class AppProvider with ChangeNotifier {
if (target.publicKeyHex.isNotEmpty) { if (target.publicKeyHex.isNotEmpty) {
return 'pk:${target.publicKeyHex}'; return 'pk:${target.publicKeyHex}';
} }
return 'name:${target.advName}:${target.routeSignedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; return 'name:${target.advName}:${target.routeEncodedPathLen}:${target.outPath.map((b) => b.toRadixString(16).padLeft(2, '0')).join()}';
} }
void _handleRawRouteProbeRequest(RawRouteProbeRequest request) { void _handleRawRouteProbeRequest(RawRouteProbeRequest request) {
@@ -2487,9 +2660,6 @@ class AppProvider with ChangeNotifier {
); );
return; return;
} }
if (!requester.routeSupportsLegacyRawTransport) {
return;
}
if (requester.outPath.isEmpty) { if (requester.outPath.isEmpty) {
return; return;
} }
@@ -2499,7 +2669,7 @@ class AppProvider with ChangeNotifier {
unawaited( unawaited(
connectionProvider.sendRawVoicePacket( connectionProvider.sendRawVoicePacket(
contactPath: requester.outPath, contactPath: requester.outPath,
contactPathLen: requester.routeSignedPathLen, contactPathLen: requester.routeEncodedPathLen,
payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(), payload: RawRouteProbeAck(nonce: request.nonce).encodeBinary(),
), ),
); );
@@ -2573,12 +2743,10 @@ class AppProvider with ChangeNotifier {
if (log.responseCode != 0x88) continue; if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue; if (log.rawData.length < 6) continue;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
final payloadType = (raw[3] >> 2) & 0x0F; if (decoded == null) continue;
final pathLen = raw[4]; if (decoded.payloadType != expectedPayloadType) continue;
if (payloadType != expectedPayloadType) continue; if (decoded.hopCount != message.pathLen) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs = final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -2592,13 +2760,42 @@ class AppProvider with ChangeNotifier {
return bestLog; return bestLog;
} }
DecodedLogRxRoute? _findBestMatchingAdvertRxRoute(Uint8List publicKey) {
final publicKeyHex = publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
DecodedLogRxRoute? bestRoute;
var bestDeltaMs = 999999999;
final now = DateTime.now();
for (final log in connectionProvider.bleService.packetLogs) {
if (log.responseCode != 0x88) continue;
if (log.rawData.length < 6) continue;
final decoded = LogRxRouteDecoder.decode(log.rawData);
if (decoded == null) continue;
if (decoded.payloadType != 0x04) continue;
final senderHash = decoded.originalSenderHashHex;
if (senderHash == null || !publicKeyHex.startsWith(senderHash)) continue;
final deltaMs = (log.timestamp.difference(now).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs;
bestRoute = decoded;
}
}
if (bestDeltaMs > 30000) return null;
return bestRoute;
}
List<int>? _extractPathBytesFromLog(BlePacketLog? log) { List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null; if (log == null) return null;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
if (raw.length < 6) return null; if (decoded == null || decoded.pathBytes.isEmpty) return null;
final pathLen = raw[4]; return decoded.pathBytes;
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
} }
// Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events // Removed _syncMessages() - messages are automatically synced via PUSH_CODE_MSG_WAITING events
@@ -2633,7 +2830,9 @@ class AppProvider with ChangeNotifier {
debugPrint( debugPrint(
'🔄 [AppProvider] Manual message sync requested (user initiated)', '🔄 [AppProvider] Manual message sync requested (user initiated)',
); );
final messageCount = await connectionProvider.syncAllMessages(); final messageCount = await connectionProvider.syncAllMessages(
force: true,
);
debugPrint( debugPrint(
'✅ [AppProvider] Manual sync completed: $messageCount messages', '✅ [AppProvider] Manual sync completed: $messageCount messages',
); );
@@ -2648,6 +2847,8 @@ class AppProvider with ChangeNotifier {
/// Handle connection state changes to manage location tracking /// Handle connection state changes to manage location tracking
void _handleConnectionStateChange() { void _handleConnectionStateChange() {
final isConnected = connectionProvider.deviceInfo.isConnected; final isConnected = connectionProvider.deviceInfo.isConnected;
final wasConnected = _wasDeviceConnected;
_wasDeviceConnected = isConnected;
final wasTracking = locationTrackingService.isTracking; final wasTracking = locationTrackingService.isTracking;
// Only stop tracking on disconnect - DON'T start on connect // Only stop tracking on disconnect - DON'T start on connect
@@ -2659,6 +2860,33 @@ class AppProvider with ChangeNotifier {
); );
_stopLocationTracking(); _stopLocationTracking();
} }
if (!isConnected) {
connectionProvider.clearPendingAutomaticMessageSync();
}
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) {
_isReconnectSyncInProgress = true;
unawaited(_syncAfterReconnect(started: true));
}
}
bool _canStartAutomaticMessageSync() {
return connectionProvider.deviceInfo.isConnected &&
_hasCompletedConnectionBootstrap &&
!_isReconnectSyncInProgress;
}
Future<void> _flushDeferredAutomaticMessageSync() async {
if (!connectionProvider.hasPendingAutomaticMessageSync ||
!_canStartAutomaticMessageSync()) {
return;
}
debugPrint(
'🔄 [AppProvider] Running deferred automatic message sync after bootstrap',
);
await connectionProvider.syncAllMessages(force: true);
} }
/// Start location tracking /// Start location tracking

View File

@@ -185,9 +185,12 @@ class ConnectionProvider with ChangeNotifier {
onMessageEchoDetected; onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived; Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
bool Function()? canStartAutomaticMessageSyncCallback;
// Track pending send operations for auto-recovery // Track pending send operations for auto-recovery
final Map<String, _PendingSendOperation> _pendingSendOperations = {}; final Map<String, _PendingSendOperation> _pendingSendOperations = {};
bool _pendingAutomaticMessageSync = false;
ConnectionProvider() { ConnectionProvider() {
_wireServiceCallbacks(_bleService); _wireServiceCallbacks(_bleService);
@@ -332,6 +335,13 @@ class ConnectionProvider with ChangeNotifier {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan'); debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return; return;
} }
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
'📥 [Provider] MSG_WAITING deferred until connection bootstrap completes',
);
return;
}
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing'); debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
if (_isSyncingMessages) { if (_isSyncingMessages) {
_syncRequestedWhileBusy = true; _syncRequestedWhileBusy = true;
@@ -486,6 +496,14 @@ class ConnectionProvider with ChangeNotifier {
/// Start scanning for MeshCore devices /// Start scanning for MeshCore devices
Future<void> startScan() async { Future<void> startScan() async {
debugPrint('🔍 [Provider] startScan() called'); debugPrint('🔍 [Provider] startScan() called');
if (_deviceInfo.connectionState == ConnectionState.connecting ||
_deviceInfo.connectionState == ConnectionState.connected) {
debugPrint(
'⏭️ [Provider] Ignoring scan request while connection is active: ${_deviceInfo.connectionState}',
);
return;
}
_isScanning = true; _isScanning = true;
_scannedDevices.clear(); _scannedDevices.clear();
_error = null; _error = null;
@@ -559,6 +577,11 @@ class ConnectionProvider with ChangeNotifier {
'🔵 [Provider] connect() called for device: ${device.platformName}', '🔵 [Provider] connect() called for device: ${device.platformName}',
); );
if (_isScanning) {
debugPrint('🔵 [Provider] Stopping active scan before connect()');
await stopScan();
}
_deviceInfo = _deviceInfo.copyWith( _deviceInfo = _deviceInfo.copyWith(
deviceId: device.remoteId.toString(), deviceId: device.remoteId.toString(),
deviceName: device.platformName.isNotEmpty deviceName: device.platformName.isNotEmpty
@@ -835,10 +858,26 @@ class ConnectionProvider with ChangeNotifier {
return true; return true;
} catch (e) { } catch (e) {
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e'); debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
if (_isEmptyChannelQueryError(e)) {
debugPrint(
' Treating slot $channelIdx as empty because the device reported it was not found',
);
return true;
}
return false; return false;
} }
} }
bool _isEmptyChannelQueryError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('not found');
}
bool _isChannelRefreshTimeoutError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('0x1f') && message.contains('timed out');
}
Future<int?> findNextEmptyChannelSlot() async { Future<int?> findNextEmptyChannelSlot() async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
throw Exception('Not connected to device'); throw Exception('Not connected to device');
@@ -849,6 +888,35 @@ class ConnectionProvider with ChangeNotifier {
// maxChannels from device info, or default to 40 // maxChannels from device info, or default to 40
final maxChannels = _deviceInfo.maxChannels ?? 40; final maxChannels = _deviceInfo.maxChannels ?? 40;
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
// Match meshcore-open: trust the current synced channel list first and
// pick the first missing slot rather than probing every slot on-device.
if (getChannelInfo != null) {
final usedIndices = <int>{};
for (int i = 1; i < maxChannels; i++) {
final channel = getChannelInfo!(i);
if (channel == null) {
continue;
}
final channelName = (channel as dynamic).name as String?;
if (channelName != null && channelName.isNotEmpty) {
usedIndices.add(i);
}
}
for (int i = 1; i < maxChannels; i++) {
if (!usedIndices.contains(i)) {
debugPrint(' ✅ Found empty slot from synced channels: $i');
return i;
}
}
debugPrint(
' ⚠️ Synced channels report all custom slots occupied ($maxCustomChannels total)',
);
}
// Check each slot starting from 1 (skip 0 = public channel) // Check each slot starting from 1 (skip 0 = public channel)
for (int i = 1; i < maxChannels; i++) { for (int i = 1; i < maxChannels; i++) {
@@ -945,10 +1013,12 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Using existing slot: $slotIdx (overwrite mode)'); debugPrint(' Using existing slot: $slotIdx (overwrite mode)');
} else { } else {
// Find next empty slot for new channel // Find next empty slot for new channel
final maxChannels = _deviceInfo.maxChannels ?? 40;
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
final emptySlot = await findNextEmptyChannelSlot(); final emptySlot = await findNextEmptyChannelSlot();
if (emptySlot == null) { if (emptySlot == null) {
throw Exception( throw Exception(
'All channel slots are in use (maximum 39 custom channels)', 'All channel slots are in use (maximum $maxCustomChannels custom channels)',
); );
} }
slotIdx = emptySlot; slotIdx = emptySlot;
@@ -969,23 +1039,33 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Secret converted to 16-byte key using MD5'); debugPrint(' Secret converted to 16-byte key using MD5');
} }
// Send CMD_SET_CHANNEL to radio // Send CMD_SET_CHANNEL to radio. Some devices accept the write but do not
// reliably answer the follow-up CMD_GET_CHANNEL verification (0x1F).
try {
await _activeService.setChannel( await _activeService.setChannel(
channelIdx: slotIdx, channelIdx: slotIdx,
channelName: channelName, channelName: channelName,
secret: secretBytes, secret: secretBytes,
); );
} catch (e) {
if (_isChannelRefreshTimeoutError(e)) {
debugPrint(
'⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded',
);
onChannelInfoReceived?.call(
slotIdx,
channelName,
Uint8List.fromList(secretBytes),
null,
);
} else {
rethrow;
}
}
debugPrint( debugPrint(
'✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx', '✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx',
); );
// Small delay to allow the response to propagate
await Future.delayed(const Duration(milliseconds: 100));
// Refresh channels to update UI
// The channel info will be received via onChannelInfoReceived callback
await _activeService.getChannel(slotIdx);
} catch (e) { } catch (e) {
_error = 'Failed to create channel: $e'; _error = 'Failed to create channel: $e';
debugPrint('❌ [Provider] Channel creation failed: $e'); debugPrint('❌ [Provider] Channel creation failed: $e');
@@ -1094,6 +1174,7 @@ class ConnectionProvider with ChangeNotifier {
} }
var effectiveContact = contact; var effectiveContact = contact;
effectiveContact ??= resolveContactForDmCallback?.call(contactPublicKey);
if (messageId != null && if (messageId != null &&
effectiveContact != null && effectiveContact != null &&
prepareDirectMessageSendCallback != null) { prepareDirectMessageSendCallback != null) {
@@ -1822,11 +1903,18 @@ class ConnectionProvider with ChangeNotifier {
} }
/// Sync all waiting messages from device /// Sync all waiting messages from device
Future<int> syncAllMessages() async { Future<int> syncAllMessages({bool force = false}) async {
if (_isSpectrumScanActive) { if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan'); debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0; return 0;
} }
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
'⏸️ [Provider] Message sync deferred until connection bootstrap completes',
);
return 0;
}
if (_isSyncingMessages) { if (_isSyncingMessages) {
// Already syncing; avoid overlapping loops // Already syncing; avoid overlapping loops
_syncRequestedWhileBusy = true; _syncRequestedWhileBusy = true;
@@ -1842,6 +1930,7 @@ class ConnectionProvider with ChangeNotifier {
int totalCount = 0; int totalCount = 0;
try { try {
_pendingAutomaticMessageSync = false;
_isSyncingMessages = true; _isSyncingMessages = true;
do { do {
_syncRequestedWhileBusy = false; _syncRequestedWhileBusy = false;
@@ -1933,6 +2022,12 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
bool get hasPendingAutomaticMessageSync => _pendingAutomaticMessageSync;
void clearPendingAutomaticMessageSync() {
_pendingAutomaticMessageSync = false;
}
/// Login to a room or repeater /// Login to a room or repeater
/// ///
/// Sends login request with password. Results will be delivered via /// Sends login request with password. Results will be delivered via

View File

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

View File

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

View File

@@ -86,6 +86,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,
@@ -165,8 +166,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 +218,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) =>
@@ -749,9 +770,17 @@ class MessagesProvider with ChangeNotifier {
final idx = channelIdx ?? 0; final idx = channelIdx ?? 0;
final resolved = resolveChannelNameCallback?.call(idx).trim(); final resolved = resolveChannelNameCallback?.call(idx).trim();
if (resolved != null && resolved.isNotEmpty) { if (resolved != null && resolved.isNotEmpty) {
if (idx == 0 && resolved == 'Public') {
return _localizations?.publicChannel ?? resolved;
}
return resolved; return resolved;
} }
return idx == 0 ? 'Public' : 'Channel $idx'; if (idx == 0) {
return _localizations?.publicChannel ?? 'Public';
}
return _localizations != null
? '${_localizations!.channel} $idx'
: 'Channel $idx';
} }
String _buildNotificationMessageText( String _buildNotificationMessageText(
@@ -765,14 +794,14 @@ class MessagesProvider with ChangeNotifier {
final seconds = (voiceEnvelope.durationMs / 1000).ceil(); final seconds = (voiceEnvelope.durationMs / 1000).ceil();
final summary = final summary =
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets'; 'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
return isChannelMessage ? '$senderName\n$summary' : summary; return summary;
} }
final imageEnvelope = ImageEnvelope.tryParse(message.text); final imageEnvelope = ImageEnvelope.tryParse(message.text);
if (imageEnvelope != null) { if (imageEnvelope != null) {
final summary = final summary =
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}'; 'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
return isChannelMessage ? '$senderName\n$summary' : summary; return summary;
} }
if (!isChannelMessage && message.recipientPublicKey != null) { if (!isChannelMessage && message.recipientPublicKey != null) {
@@ -785,10 +814,6 @@ class MessagesProvider with ChangeNotifier {
} }
} }
if (isChannelMessage) {
return '$senderName\n${message.text}';
}
return message.text; return message.text;
} }
@@ -889,6 +914,55 @@ class MessagesProvider with ChangeNotifier {
} }
} }
/// Mark unread messages for a specific destination as read.
void markDestinationAsRead({
required String destinationType,
Contact? contact,
}) {
if (destinationType == 'all') {
markAllAsRead();
return;
}
bool hasChanges = false;
for (int i = 0; i < _messages.length; i++) {
final message = _messages[i];
if (message.isRead || message.isSentMessage || message.isSystemMessage) {
continue;
}
final matchesDestination = switch (destinationType) {
'channel' => _isChannelMessageForContact(message, contact),
'contact' ||
'room' => contact != null && _isMessageForDestination(message, contact),
_ => false,
};
if (!matchesDestination) {
continue;
}
_messages[i] = message.copyWith(isRead: true);
hasChanges = true;
}
if (hasChanges) {
_persistMessages();
notifyListeners();
}
}
bool _isChannelMessageForContact(Message message, Contact? contact) {
if (!message.isChannelMessage) {
return false;
}
final selectedChannelIdx = contact != null && contact.publicKey.length > 1
? contact.publicKey[1]
: 0;
return (message.channelIdx ?? 0) == selectedChannelIdx;
}
/// Mark a specific message as read /// Mark a specific message as read
void markAsRead(String messageId) { void markAsRead(String messageId) {
final index = _messages.indexWhere((m) => m.id == messageId); final index = _messages.indexWhere((m) => m.id == messageId);
@@ -2000,6 +2074,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,

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,9 +14,11 @@ import 'messages_tab.dart';
import 'contacts_tab.dart'; import 'contacts_tab.dart';
import 'sensors_tab.dart'; import 'sensors_tab.dart';
import 'map_tab.dart'; import 'map_tab.dart';
import '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';
@@ -24,6 +26,7 @@ import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart'; import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart'; import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart'; import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart';
enum _HomeTab { messages, contacts, sensors, map } enum _HomeTab { messages, contacts, sensors, map }
@@ -93,6 +96,7 @@ class _HomeScreenState extends State<HomeScreen>
_initTabController(); _initTabController();
_loadRxTxPreference(); _loadRxTxPreference();
_loadDeveloperModePreference(); _loadDeveloperModePreference();
MeshMapNodesService.syncInBackgroundIfStale();
// Show permission dialog after the first frame if needed // Show permission dialog after the first frame if needed
if (widget.shouldShowPermissionDialog) { if (widget.shouldShowPermissionDialog) {
@@ -195,7 +199,6 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) { switch (tab) {
case _HomeTab.messages: case _HomeTab.messages:
context.read<MessagesProvider>().markAllAsRead();
break; break;
case _HomeTab.contacts: case _HomeTab.contacts:
context.read<ContactsProvider>().markAllAsViewed(); context.read<ContactsProvider>().markAllAsViewed();
@@ -216,10 +219,17 @@ 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;
_syncFastLocationUiState(); _syncFastLocationUiState();
if (state == AppLifecycleState.resumed) {
MeshMapNodesService.syncInBackgroundIfStale();
}
} }
Future<void> _loadRxTxPreference() async { Future<void> _loadRxTxPreference() async {
@@ -389,10 +399,14 @@ class _HomeScreenState extends State<HomeScreen>
required int count, required int count,
required bool isActive, required bool isActive,
required Color activeColor, required Color activeColor,
bool compact = false,
}) { }) {
final color = isActive ? activeColor : Colors.grey; final color = isActive ? activeColor : Colors.grey;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 8,
vertical: compact ? 4 : 5,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.12), color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
@@ -401,15 +415,15 @@ class _HomeScreenState extends State<HomeScreen>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 7, width: compact ? 6 : 7,
height: 7, height: compact ? 6 : 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color), decoration: BoxDecoration(shape: BoxShape.circle, color: color),
), ),
const SizedBox(width: 6), SizedBox(width: compact ? 5 : 6),
Text( Text(
'$label:$count', '$label:$count',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: compact ? 10 : 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: color, color: color,
), ),
@@ -638,6 +652,66 @@ 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( items.add(
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
@@ -738,7 +812,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(
@@ -1022,6 +1096,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,
@@ -1037,17 +1112,19 @@ class _HomeScreenState extends State<HomeScreen>
txActive: provider.txActivity, txActive: provider.txActivity,
) )
: Container( : Container(
constraints: const BoxConstraints(minHeight: 48),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, horizontal: 8,
vertical: 8, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh color: theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.85), .withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(22),
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
_buildActivityBadge( _buildActivityBadge(
@@ -1055,13 +1132,15 @@ class _HomeScreenState extends State<HomeScreen>
count: provider.rxPacketCount, count: provider.rxPacketCount,
isActive: provider.rxActivity, isActive: provider.rxActivity,
activeColor: Colors.green, activeColor: Colors.green,
compact: true,
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
_buildActivityBadge( _buildActivityBadge(
label: 'TX', label: 'TX',
count: provider.txPacketCount, count: provider.txPacketCount,
isActive: provider.txActivity, isActive: provider.txActivity,
activeColor: Colors.blue, activeColor: Colors.blue,
compact: true,
), ),
], ],
), ),

File diff suppressed because it is too large Load Diff

View File

@@ -27,6 +27,7 @@ import '../widgets/map_debug_info.dart';
import '../widgets/map/compass_widget.dart'; import '../widgets/map/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';
@@ -2131,6 +2132,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

@@ -71,7 +71,7 @@ class _MessagesTabState extends State<MessagesTab> {
final VoiceRecorderService _voiceRecorder = VoiceRecorderService(); final VoiceRecorderService _voiceRecorder = VoiceRecorderService();
bool _isRecording = false; bool _isRecording = false;
bool _isSendingVoice = false; bool _isSendingVoice = false;
static const int _maxVoicePackets = 10; static const Duration _maxVoiceRecordingDuration = Duration(seconds: 30);
static const double _silenceRmsThreshold = 500.0; static const double _silenceRmsThreshold = 500.0;
static const double _silencePeakThreshold = 1400.0; static const double _silencePeakThreshold = 1400.0;
static const int _maxInteriorSilentChunks = 2; static const int _maxInteriorSilentChunks = 2;
@@ -88,15 +88,13 @@ class _MessagesTabState extends State<MessagesTab> {
_textController.addListener(_updateCharacterCount); _textController.addListener(_updateCharacterCount);
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceBitrate(); _loadVoiceSettings();
// Mark all messages as read when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MessagesProvider>().markAllAsRead();
_checkForNavigationRequest(); _checkForNavigationRequest();
}); });
} }
Future<void> _loadVoiceBitrate() async { Future<void> _loadVoiceSettings() async {
final bitrate = await VoiceBitratePreferences.getBitrate(); final bitrate = await VoiceBitratePreferences.getBitrate();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -309,6 +307,8 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedRecipient = recipient; _selectedRecipient = recipient;
}); });
_markCurrentDestinationAsRead();
_enforceMessageByteLimit(); _enforceMessageByteLimit();
// Save to preferences // Save to preferences
@@ -338,9 +338,35 @@ class _MessagesTabState extends State<MessagesTab> {
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
void _insertReplyMention(String displayName) {
final trimmedName = displayName.trim();
if (trimmedName.isEmpty) return;
final mention = '@[$trimmedName] ';
final value = _textController.value;
final selection = value.selection;
final hasSelection =
selection.isValid &&
selection.start >= 0 &&
selection.end >= selection.start;
final start = hasSelection ? selection.start : value.text.length;
final end = hasSelection ? selection.end : value.text.length;
final nextText = value.text.replaceRange(start, end, mention);
final nextOffset = start + mention.length;
_textController.value = value.copyWith(
text: nextText,
selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty,
);
_enforceMessageByteLimit();
}
Future<void> _replyToMessage(Message message) async { 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 destinationType; String destinationType;
Contact? recipient; Contact? recipient;
@@ -360,6 +386,11 @@ class _MessagesTabState extends State<MessagesTab> {
return; return;
} }
} }
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
senderContact = contactsProvider.findContactByPrefix(senderPrefix);
}
} else { } else {
final senderPrefix = message.senderPublicKeyPrefix; final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix == null || senderPrefix.length < 6) { if (senderPrefix == null || senderPrefix.length < 6) {
@@ -380,6 +411,9 @@ class _MessagesTabState extends State<MessagesTab> {
await _onRecipientSelected(destinationType, recipient); await _onRecipientSelected(destinationType, recipient);
if (!mounted) return; if (!mounted) return;
if (message.isChannelMessage && senderContact != null) {
_insertReplyMention(senderContact.displayName);
}
_focusNode.requestFocus(); _focusNode.requestFocus();
} }
@@ -465,6 +499,7 @@ class _MessagesTabState extends State<MessagesTab> {
_textController.clear(); _textController.clear();
_focusNode.unfocus(); _focusNode.unfocus();
_markCurrentDestinationAsRead();
if (!mounted) return; if (!mounted) return;
} catch (e) { } catch (e) {
@@ -782,7 +817,7 @@ class _MessagesTabState extends State<MessagesTab> {
Future<void> _startVoiceRecording() async { Future<void> _startVoiceRecording() async {
if (_isSendingVoice || _isRecording) return; if (_isSendingVoice || _isRecording) return;
debugPrint('🎙️ [Voice] _startVoiceRecording called'); debugPrint('🎙️ [Voice] _startVoiceRecording called');
// Read fresh bitrate preference so settings changes apply immediately. // Read fresh voice preferences so settings changes apply immediately.
final selectedBitrate = await VoiceBitratePreferences.getBitrate(); final selectedBitrate = await VoiceBitratePreferences.getBitrate();
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -821,11 +856,12 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedVoiceBitrate, _selectedVoiceBitrate,
); );
final packetDuration = Duration( final packetDuration = Duration(
milliseconds: codec2ModeFor(_activeVoiceMode!).packetDurationMs, milliseconds: _activeVoiceMode!.packetDurationMs,
); );
final maxVoicePackets = _maxVoicePacketsForMode(_activeVoiceMode!);
debugPrint( debugPrint(
'🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms', '🎙️ [Voice] session=$_currentVoiceSessionId mode=$_activeVoiceMode chunkDuration=${packetDuration.inMilliseconds}ms maxPackets=$maxVoicePackets',
); );
_recordedChunks.clear(); _recordedChunks.clear();
@@ -834,9 +870,13 @@ class _MessagesTabState extends State<MessagesTab> {
try { try {
final stream = _voiceRecorder.startCapture( final stream = _voiceRecorder.startCapture(
chunkDuration: packetDuration, chunkDuration: packetDuration,
sampleRateHz: _activeVoiceMode!.sampleRateHz,
enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled, enableBandPassFilter: appProvider.isVoiceBandPassFilterEnabled,
enableCompressor: appProvider.isVoiceCompressorEnabled, enableCompressor: appProvider.isVoiceCompressorEnabled,
enableLimiter: appProvider.isVoiceLimiterEnabled, enableLimiter: appProvider.isVoiceLimiterEnabled,
enableAutoGain: appProvider.isVoiceAutoGainEnabled,
enableEchoCancellation: appProvider.isVoiceEchoCancellationEnabled,
enableNoiseSuppression: appProvider.isVoiceNoiseSuppressionEnabled,
); );
debugPrint('🎙️ [Voice] capture started, listening for chunks...'); debugPrint('🎙️ [Voice] capture started, listening for chunks...');
_voiceStreamSub = stream.listen( _voiceStreamSub = stream.listen(
@@ -847,7 +887,7 @@ class _MessagesTabState extends State<MessagesTab> {
'🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples', '🎙️ [Voice] chunk #${_recordedChunks.length} received: ${pcmChunk.length} samples',
); );
setState(() {}); setState(() {});
if (_recordedChunks.length >= _maxVoicePackets) { if (_recordedChunks.length >= maxVoicePackets) {
debugPrint('🎙️ [Voice] max packets reached, stopping'); debugPrint('🎙️ [Voice] max packets reached, stopping');
_stopAndSendVoice(); _stopAndSendVoice();
} }
@@ -864,6 +904,12 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
int _maxVoicePacketsForMode(VoicePacketMode mode) {
final packets =
_maxVoiceRecordingDuration.inMilliseconds ~/ mode.packetDurationMs;
return packets < 1 ? 1 : packets;
}
Future<void> _stopAndSendVoice() async { Future<void> _stopAndSendVoice() async {
if (!_isRecording) return; if (!_isRecording) return;
final trimSilenceEnabled = context final trimSilenceEnabled = context
@@ -878,9 +924,14 @@ class _MessagesTabState extends State<MessagesTab> {
await _voiceRecorder.stopCapture(); await _voiceRecorder.stopCapture();
final rawChunks = List<Int16List>.from(_recordedChunks); final rawChunks = List<Int16List>.from(_recordedChunks);
final chunks = trimSilenceEnabled ? _trimSilence(rawChunks) : rawChunks; final trimmedChunks = trimSilenceEnabled
? _trimSilence(rawChunks)
: rawChunks;
final sessionId = _currentVoiceSessionId; final sessionId = _currentVoiceSessionId;
final mode = _activeVoiceMode; final mode = _activeVoiceMode;
final chunks = mode == null
? trimmedChunks
: _prepareChunksForSending(trimmedChunks, mode);
_recordedChunks.clear(); _recordedChunks.clear();
debugPrint( debugPrint(
@@ -1069,6 +1120,13 @@ class _MessagesTabState extends State<MessagesTab> {
messagesProvider.markMessageSent(msgId, 0, 0); messagesProvider.markMessageSent(msgId, 0, 0);
} }
List<Int16List> _prepareChunksForSending(
List<Int16List> chunks,
VoicePacketMode mode,
) {
return chunks;
}
List<Int16List> _trimSilence(List<Int16List> chunks) { List<Int16List> _trimSilence(List<Int16List> chunks) {
if (chunks.isEmpty) return chunks; if (chunks.isEmpty) return chunks;
@@ -1098,16 +1156,28 @@ class _MessagesTabState extends State<MessagesTab> {
bool _isSilentChunk(Int16List chunk) { bool _isSilentChunk(Int16List chunk) {
if (chunk.isEmpty) return true; if (chunk.isEmpty) return true;
final rms = _chunkRms(chunk);
final peak = _chunkPeak(chunk);
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
}
double _chunkRms(Int16List chunk) {
var sumSquares = 0.0; var sumSquares = 0.0;
for (final sample in chunk) {
sumSquares += sample * sample;
}
return math.sqrt(sumSquares / chunk.length);
}
int _chunkPeak(Int16List chunk) {
var peak = 0; var peak = 0;
for (final sample in chunk) { for (final sample in chunk) {
final absSample = sample.abs(); final absSample = sample.abs();
if (absSample > peak) peak = absSample; if (absSample > peak) {
sumSquares += sample * sample; peak = absSample;
} }
}
final rms = math.sqrt(sumSquares / chunk.length); return peak;
return rms < _silenceRmsThreshold && peak < _silencePeakThreshold;
} }
bool _isPublicChannelSelected() { bool _isPublicChannelSelected() {
@@ -1641,6 +1711,13 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
void _markCurrentDestinationAsRead() {
context.read<MessagesProvider>().markDestinationAsRead(
destinationType: _destinationType,
contact: _selectedRecipient,
);
}
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) { List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages // Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100); final allMessages = messagesProvider.getRecentMessages(count: 100);

View File

@@ -8,6 +8,7 @@ import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart'; import '../utils/log_rx_route_decoder.dart';
@@ -746,22 +747,31 @@ class _DecodedRouteSection extends StatelessWidget {
connectionProvider.deviceInfo.selfName ?? connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName; connectionProvider.deviceInfo.displayName;
return FutureBuilder<int>( return FutureBuilder<List<dynamic>>(
future: RouteHashPreferences.getHashSize(), future: Future.wait<dynamic>([
RouteHashPreferences.getHashSize(),
MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
]),
builder: (context, snapshot) { builder: (context, snapshot) {
final decodedRoute = LogRxRouteDecoder.decode( final decodedRoute = LogRxRouteDecoder.decode(
log.rawData, log.rawData,
preferredHashSize: snapshot.data, preferredHashSize: snapshot.data?.first as int?,
); );
if (decodedRoute == null) { if (decodedRoute == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final cachedNodes = snapshot.hasData
? snapshot.data![1] as List<MeshMapNode>
: const <MeshMapNode>[];
final resolvedPath = decodedRoute.hopHashes final resolvedPath = decodedRoute.hopHashes
.map( .map(
(hashHex) => LogRxRouteDecoder.resolveHash( (hashHex) => _resolveHashWithFallback(
hashHex, hashHex,
contacts: contacts, contacts: contacts,
cachedNodes: cachedNodes,
ownPublicKey: ownPublicKey, ownPublicKey: ownPublicKey,
ownName: ownName, ownName: ownName,
), ),
@@ -777,6 +787,41 @@ class _DecodedRouteSection extends StatelessWidget {
}, },
); );
} }
ResolvedNodeHash _resolveHashWithFallback(
String hashHex, {
required List<Contact> contacts,
required List<MeshMapNode> cachedNodes,
required Uint8List? ownPublicKey,
required String? ownName,
}) {
final localResolved = LogRxRouteDecoder.resolveHash(
hashHex,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
);
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
return localResolved;
}
final normalizedHashHex = hashHex.toLowerCase();
final matches = cachedNodes
.where((node) => node.publicKey.startsWith(normalizedHashHex))
.toList();
if (matches.isEmpty) {
return localResolved;
}
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return ResolvedNodeHash(
hashHex: normalizedHashHex,
label: matches.first.name,
isOwnNode: false,
isUniqueMatch: matches.length == 1,
matchCount: matches.length,
);
}
} }
class _RouteSection extends StatelessWidget { class _RouteSection extends StatelessWidget {

View File

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

View File

@@ -1,5 +1,6 @@
import 'dart:io' show Platform;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart'; import 'package:flutter_avif/flutter_avif.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@@ -15,8 +16,8 @@ import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../services/location_tracking_service.dart'; import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart'; import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/update_checker_service.dart'; import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart'; import '../services/route_hash_preferences.dart';
@@ -72,6 +73,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
double _fastLocationMovementThresholdMeters = 10.0; double _fastLocationMovementThresholdMeters = 10.0;
int _fastLocationActiveCadenceSeconds = 10; int _fastLocationActiveCadenceSeconds = 10;
bool _isDeveloperModeEnabled = false; bool _isDeveloperModeEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0; int _versionTapCount = 0;
final ImagePicker _imagePicker = ImagePicker(); final ImagePicker _imagePicker = ImagePicker();
final LocationTrackingService _locationService = LocationTrackingService(); final LocationTrackingService _locationService = LocationTrackingService();
@@ -84,11 +87,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadPackageInfo(); _loadPackageInfo();
_initializeLocationService(); _initializeLocationService();
_loadRxTxPreference(); _loadRxTxPreference();
_loadVoiceBitratePreference(); _loadVoicePreferences();
_loadRouteHashSizePreference(); _loadRouteHashSizePreference();
_loadImagePreferences(); _loadImagePreferences();
_loadFastLocationSettings(); _loadFastLocationSettings();
_loadDeveloperMode(); _loadDeveloperMode();
_loadOnlineTraceCacheStatus();
} }
@override @override
@@ -126,6 +130,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
}); });
} }
Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = cachedAt;
});
}
Future<void> _handleVersionTap() async { Future<void> _handleVersionTap() async {
if (_isDeveloperModeEnabled) { if (_isDeveloperModeEnabled) {
await DeveloperModeService.setEnabled(false); await DeveloperModeService.setEnabled(false);
@@ -165,7 +177,7 @@ 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> _loadVoicePreferences() async {
final value = await VoiceBitratePreferences.getBitrate(); final value = await VoiceBitratePreferences.getBitrate();
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -470,7 +482,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// Check for app updates and show notification or dialog /// Check for app updates and show notification or dialog
Future<void> _checkForUpdates() async { Future<void> _checkForUpdates() async {
// Only on Android // Only on Android
if (!Platform.isAndroid) { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -829,6 +841,66 @@ class _SettingsScreenState extends State<SettingsScreen> {
); );
} }
Future<void> _clearOnlineTraceCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear online trace database'),
content: const Text(
'This removes the cached online node database used as a trace fallback.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.clear),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() {
_isClearingOnlineTraceCache = true;
});
await MeshMapNodesService.clearCache();
if (!mounted) return;
setState(() {
_onlineTraceCacheUpdatedAt = null;
_isClearingOnlineTraceCache = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Online trace database cleared'),
backgroundColor: Colors.orange,
),
);
}
String _onlineTraceCacheSubtitle() {
final cachedAt = _onlineTraceCacheUpdatedAt;
if (cachedAt == null) {
return 'No cached online database. Refresh runs in background when internet is available.';
}
final expiresAt = cachedAt.add(MeshMapNodesService.traceCacheTtl);
return 'Last synced ${_formatDateTime(cachedAt)}. Cached for 24 hours until ${_formatDateTime(expiresAt)}.';
}
String _formatDateTime(DateTime value) {
final local = value.toLocal();
String two(int part) => part.toString().padLeft(2, '0');
return '${local.year}-${two(local.month)}-${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
}
Future<void> _showRouteHashSizeDialog() async { Future<void> _showRouteHashSizeDialog() async {
final selected = await showDialog<int>( final selected = await showDialog<int>(
context: context, context: context,
@@ -1047,6 +1119,41 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
_buildSectionHeader('Tracing'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.cloud_sync),
title: const Text('Online trace database'),
subtitle: Text(_onlineTraceCacheSubtitle()),
),
ListTile(
leading: Icon(
Icons.delete_sweep,
color: _isClearingOnlineTraceCache ? null : Colors.red,
),
title: Text(
'Clear online trace database',
style: TextStyle(
color: _isClearingOnlineTraceCache ? null : Colors.red,
),
),
subtitle: const Text(
'Remove the 24-hour cached fallback used when local route matches are incomplete',
),
enabled: !_isClearingOnlineTraceCache,
trailing: _isClearingOnlineTraceCache
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
onTap: _isClearingOnlineTraceCache
? null
: _clearOnlineTraceCache,
),
]),
_buildSectionHeader('Voice'), _buildSectionHeader('Voice'),
Consumer2<AppProvider, ConnectionProvider>( Consumer2<AppProvider, ConnectionProvider>(
builder: (context, appProvider, connectionProvider, child) => builder: (context, appProvider, connectionProvider, child) =>
@@ -1056,6 +1163,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,
), ),
), ),
@@ -1102,6 +1214,43 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
), ),
), ),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.auto_fix_high),
title: const Text('Mic auto gain'),
subtitle: const Text('Lets the recorder adjust input level'),
value: appProvider.isVoiceAutoGainEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceAutoGainEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.hearing_disabled),
title: const Text('Echo cancellation'),
subtitle: const Text(
'Uses recorder echo cancellation if available',
),
value: appProvider.isVoiceEchoCancellationEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceEchoCancellationEnabled(value);
},
),
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.noise_control_off),
title: const Text('Noise suppression'),
subtitle: const Text(
'Uses recorder noise suppression if available',
),
value: appProvider.isVoiceNoiseSuppressionEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceNoiseSuppressionEnabled(value);
},
),
),
Consumer<AppProvider>( Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile( builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut), secondary: const Icon(Icons.content_cut),
@@ -1213,8 +1362,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
if (!kIsWeb) ...[
_buildSectionHeader('Network Sharing'), _buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(), const ConnectionModeSelector(),
],
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection), _buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([ _buildSettingsCard([
@@ -1320,7 +1471,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: () => _showAboutDialog(), onTap: () => _showAboutDialog(),
), ),
]), ]),
if (Platform.isAndroid) if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: FilledButton.icon( child: FilledButton.icon(
@@ -1619,6 +1770,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
required bool bandPassEnabled, required bool bandPassEnabled,
required bool compressorEnabled, required bool compressorEnabled,
required bool limiterEnabled, required bool limiterEnabled,
required bool autoGainEnabled,
required bool echoCancellationEnabled,
required bool noiseSuppressionEnabled,
required bool silenceTrimEnabled, required bool silenceTrimEnabled,
}) { }) {
final supported = VoiceBitratePreferences.supportedBitrates; final supported = VoiceBitratePreferences.supportedBitrates;
@@ -1631,6 +1785,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
(bandPassEnabled ? 1 : 0) + (bandPassEnabled ? 1 : 0) +
(compressorEnabled ? 1 : 0) + (compressorEnabled ? 1 : 0) +
(limiterEnabled ? 1 : 0) + (limiterEnabled ? 1 : 0) +
(autoGainEnabled ? 1 : 0) +
(echoCancellationEnabled ? 1 : 0) +
(noiseSuppressionEnabled ? 1 : 0) +
(silenceTrimEnabled ? 1 : 0); (silenceTrimEnabled ? 1 : 0);
final radioBw = connectionProvider.deviceInfo.radioBw; final radioBw = connectionProvider.deviceInfo.radioBw;
final radioSf = connectionProvider.deviceInfo.radioSf; final radioSf = connectionProvider.deviceInfo.radioSf;
@@ -1638,7 +1795,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final bwHz = _resolveBandwidthHz(radioBw); final bwHz = _resolveBandwidthHz(radioBw);
final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate); final voiceMode = VoiceBitratePreferences.toVoiceMode(bitrate);
const voicePreviewMs = 10000; // 10-second reference clip const voicePreviewMs = 10000; // 10-second reference clip
final packetDurationMs = codec2ModeFor(voiceMode).packetDurationMs; final packetDurationMs = voiceMode.packetDurationMs;
final voicePacketCount = final voicePacketCount =
(voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs; (voicePreviewMs + packetDurationMs - 1) ~/ packetDurationMs;
final voiceDirect = estimateVoiceTransmitDuration( final voiceDirect = estimateVoiceTransmitDuration(
@@ -1673,7 +1830,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Bitrate: $bitrate bps', 'Codec: ${voiceMode.label} · $bitrate bps',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -1720,6 +1877,31 @@ class _SettingsScreenState extends State<SettingsScreen> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _voiceStatChip(
label: 'Auto gain',
enabled: autoGainEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Echo cancel',
enabled: echoCancellationEnabled,
),
),
const SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Noise suppress',
enabled: noiseSuppressionEnabled,
),
),
],
),
const SizedBox(height: 8),
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -1732,7 +1914,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Processing enabled: $enabledCount/4', 'Processing enabled: $enabledCount/7',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
], ],

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

@@ -1,6 +1,5 @@
import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart';
/// Service for accessing build information from native platform code /// Service for accessing build information from native platform code
/// Currently supports Android only - returns "unknown" for other platforms /// Currently supports Android only - returns "unknown" for other platforms
@@ -25,7 +24,7 @@ class BuildInfoService {
} }
// Only Android has the platform channel implementation // Only Android has the platform channel implementation
if (!Platform.isAndroid) { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"'); debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
_cachedCommitHash = 'unknown'; _cachedCommitHash = 'unknown';
return _cachedCommitHash!; return _cachedCommitHash!;

View File

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

View File

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

View File

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

View File

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

@@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class MeshMapNode { class MeshMapNode {
final int type; final int type;
@@ -33,25 +35,36 @@ class MeshMapNode {
class MeshMapNodesService { class MeshMapNodesService {
static const String _nodesEndpoint = static const String _nodesEndpoint =
'https://api.meshcore.nz/api/v1/map/nodes'; 'https://api.meshcore.nz/api/v1/map/nodes';
static const Duration _cacheTtl = Duration(minutes: 2); static const int repeaterType = 1;
static const Duration traceCacheTtl = Duration(minutes: 10); static const Duration _cacheTtl = Duration(hours: 24);
static const Duration traceCacheTtl = _cacheTtl;
static const Duration traceTimeout = Duration(seconds: 30); static const Duration traceTimeout = Duration(seconds: 30);
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
static List<MeshMapNode>? _cachedNodes; static List<MeshMapNode>? _cachedNodes;
static DateTime? _cachedAt; static DateTime? _cachedAt;
static Future<void>? _ongoingSync;
static Future<List<MeshMapNode>> fetchNodes({ static Future<List<MeshMapNode>> fetchNodes({
bool forceRefresh = false, bool forceRefresh = false,
Duration cacheTtl = _cacheTtl, Duration cacheTtl = _cacheTtl,
bool allowNetwork = true,
http.Client? client,
}) async { }) async {
final now = DateTime.now(); final now = DateTime.now();
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
if (!forceRefresh && if (!forceRefresh &&
_cachedNodes != null && cached.isNotEmpty &&
_cachedAt != null && _cachedAt != null &&
now.difference(_cachedAt!) < cacheTtl) { now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!; return cached;
} }
final response = await http if (!allowNetwork) {
return cached;
}
final response = await (client ?? http.Client())
.get(Uri.parse(_nodesEndpoint)) .get(Uri.parse(_nodesEndpoint))
.timeout(traceTimeout); .timeout(traceTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) { if (response.statusCode < 200 || response.statusCode >= 300) {
@@ -69,8 +82,133 @@ class MeshMapNodesService {
) )
.toList(); .toList();
_cachedNodes = nodes; await _storeCache(nodes, cachedAt: now);
_cachedAt = now;
return nodes; return nodes;
} }
static Future<List<MeshMapNode>> loadCachedNodes({
Duration cacheTtl = _cacheTtl,
}) async {
final now = DateTime.now();
if (_cachedNodes != null &&
_cachedAt != null &&
now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!;
}
final prefs = await SharedPreferences.getInstance();
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
final cachedJson = prefs.getString(_cacheKey);
if (cachedAtMs == null || cachedJson == null) {
_cachedNodes = null;
_cachedAt = null;
return const [];
}
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
if (now.difference(cachedAt) >= cacheTtl) {
_cachedNodes = null;
_cachedAt = cachedAt;
return const [];
}
final decoded = jsonDecode(cachedJson) as List<dynamic>;
final nodes = decoded
.whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson)
.where(
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
)
.toList();
_cachedNodes = nodes;
_cachedAt = cachedAt;
return nodes;
}
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
return nodes.isNotEmpty;
}
static Future<void> syncInBackgroundIfStale({
Duration cacheTtl = _cacheTtl,
http.Client? client,
}) async {
final now = DateTime.now();
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
return;
}
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
if (cached.isNotEmpty && _cachedAt != null) {
return;
}
if (_ongoingSync != null) {
return _ongoingSync!;
}
_ongoingSync = () async {
try {
await fetchNodes(
forceRefresh: true,
cacheTtl: cacheTtl,
allowNetwork: true,
client: client,
);
} catch (_) {
// Background refresh is best-effort only.
} finally {
_ongoingSync = null;
}
}();
return _ongoingSync!;
}
static Future<void> clearCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_cacheKey);
await prefs.remove(_cacheTimestampKey);
_cachedNodes = null;
_cachedAt = null;
}
static Future<DateTime?> cachedAt() async {
if (_cachedAt != null) return _cachedAt;
final prefs = await SharedPreferences.getInstance();
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
if (cachedAtMs == null) return null;
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
return _cachedAt;
}
static bool isRepeater(MeshMapNode node) => node.type == repeaterType;
static Future<void> _storeCache(
List<MeshMapNode> nodes, {
required DateTime cachedAt,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_cacheKey,
jsonEncode(
nodes
.map(
(node) => {
'type': node.type,
'name': node.name,
'public_key': node.publicKey,
'latitude': node.latitude,
'longitude': node.longitude,
'updated_at': node.updatedAtMs,
},
)
.toList(),
),
);
await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch);
_cachedNodes = nodes;
_cachedAt = cachedAt;
}
} }

View File

@@ -19,6 +19,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 +34,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 +139,10 @@ class MessageStorageService {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey); final jsonString = prefs.getString(_messageReceptionDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageReceptionDetails>{}; final result = <String, MessageReceptionDetails>{};
if (jsonString != null && jsonString.isNotEmpty) {
final decoded = jsonDecode(jsonString);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) { for (final entry in decoded.entries) {
final value = entry.value; final value = entry.value;
if (value is! Map<String, dynamic>) continue; if (value is! Map<String, dynamic>) continue;
@@ -147,6 +151,24 @@ class MessageStorageService {
result[entry.key] = snapshot; result[entry.key] = snapshot;
} }
} }
}
}
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
embeddedReceptionDetails.forEach((messageId, snapshot) {
result.putIfAbsent(messageId, () => snapshot);
});
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
fallbackPathBytes.forEach((messageId, pathBytes) {
result.putIfAbsent(
messageId,
() => MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
pathBytes: pathBytes,
),
);
});
return result; return result;
} catch (e) { } catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e'); debugPrint('❌ [MessageStorage] Error loading reception details: $e');
@@ -278,7 +300,10 @@ class MessageStorageService {
} }
/// Convert Message to JSON /// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) { Map<String, dynamic> _messageToJson(
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
return { return {
'id': message.id, 'id': message.id,
'messageType': message.messageType.name, 'messageType': message.messageType.name,
@@ -338,9 +363,67 @@ class MessageStorageService {
}, },
) )
.toList(), .toList(),
if (receptionDetails != null)
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
if (receptionDetails?.pathBytes case final pathBytes?)
_legacyPathBytesKey: List<int>.from(pathBytes),
}; };
} }
Future<Map<String, MessageReceptionDetails>>
_loadEmbeddedReceptionDetails() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final embedded = entry[_embeddedReceptionDetailsKey];
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(embedded);
if (snapshot == null) continue;
result[messageId] = snapshot;
}
return result;
}
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, List<int>>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final pathBytes = entry[_legacyPathBytesKey];
if (messageId is! String || pathBytes is! List) continue;
final normalized = pathBytes
.whereType<num>()
.map((b) => b.toInt())
.toList();
if (normalized.isEmpty) continue;
result[messageId] = normalized;
}
return result;
}
/// Convert JSON to Message /// Convert JSON to Message
Message? _messageFromJson(Map<String, dynamic> json) { Message? _messageFromJson(Map<String, dynamic> json) {
try { try {

View File

@@ -402,10 +402,13 @@ class NotificationService {
(DateTime.now().millisecondsSinceEpoch % 1000); (DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body // Build notification title and body
final resolvedChannelName = channelName?.trim().isNotEmpty == true
? channelName!.trim()
: (localizations?.publicChannel ?? 'Public');
final title = isChannelMessage final title = isChannelMessage
? (localizations != null ? (localizations != null
? '${localizations.channel}: ${channelName ?? "Public"}' ? '${localizations.channel}: $resolvedChannelName'
: 'Channel: ${channelName ?? "Public"}') : 'Channel: $resolvedChannelName')
: (localizations != null : (localizations != null
? '${localizations.newMessage} ${localizations.from} $senderName' ? '${localizations.newMessage} ${localizations.from} $senderName'
: 'New message from $senderName'); : 'New message from $senderName');

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,13 @@ import '../models/contact.dart';
class DecodedLogRxRoute { class DecodedLogRxRoute {
final int payloadType; final int payloadType;
final int pathDescriptor;
final List<int> pathBytes; final List<int> pathBytes;
final int hashSize; final int hashSize;
const DecodedLogRxRoute({ const DecodedLogRxRoute({
required this.payloadType, required this.payloadType,
required this.pathDescriptor,
required this.pathBytes, required this.pathBytes,
required this.hashSize, required this.hashSize,
}); });
@@ -28,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,
@@ -35,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()}';
@@ -63,21 +69,49 @@ class LogRxRouteDecoder {
} }
if (rawPacketData.length <= index) return null; if (rawPacketData.length <= index) return null;
final pathLen = rawPacketData[index++]; final pathDescriptor = rawPacketData[index++];
if (rawPacketData.length < index + pathLen) return null; final pathMode = (pathDescriptor & 0xFF) >> 6;
final pathBytes = rawPacketData.sublist(index, index + pathLen); final pathByteLen = pathMode == 0
final hashSize = inferHashSize( ? pathDescriptor
pathBytes, : descriptorByteLength(pathDescriptor);
preferredHashSize: preferredHashSize, if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
); return null;
}
final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
final hashSize = pathMode == 0
? inferHashSize(pathBytes, preferredHashSize: preferredHashSize)
: (descriptorHashSize(pathDescriptor) ??
inferHashSize(pathBytes, preferredHashSize: preferredHashSize));
return DecodedLogRxRoute( return DecodedLogRxRoute(
payloadType: payloadType, payloadType: payloadType,
pathDescriptor: pathDescriptor,
pathBytes: pathBytes, pathBytes: pathBytes,
hashSize: hashSize, hashSize: hashSize,
); );
} }
static int? descriptorHashSize(int pathDescriptor) {
final normalized = pathDescriptor & 0xFF;
final mode = normalized >> 6;
if (mode == 3) return null;
return mode + 1;
}
static int? descriptorHopCount(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
if (hashSize == null) return null;
return (pathDescriptor & 0xFF) & 0x3F;
}
static int? descriptorByteLength(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
final hopCount = descriptorHopCount(pathDescriptor);
if (hashSize == null || hopCount == null) return null;
final byteLen = hopCount * hashSize;
return byteLen <= 64 ? byteLen : null;
}
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) { static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
if (pathBytes.isEmpty) return 1; if (pathBytes.isEmpty) return 1;
@@ -124,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);
@@ -142,6 +198,8 @@ class LogRxRouteDecoder {
isOwnNode: true, isOwnNode: true,
isUniqueMatch: true, isUniqueMatch: true,
matchCount: 1, matchCount: 1,
latitude: ownLatitude,
longitude: ownLongitude,
); );
} }
@@ -160,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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,40 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'dart:math' as math;
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../providers/app_provider.dart'; import '../../providers/app_provider.dart';
import '../../services/contact_route_resolver.dart';
import '../../services/path_history_service.dart';
import '../../services/route_hash_preferences.dart'; import '../../services/route_hash_preferences.dart';
import '../../models/path_history.dart';
class ContactRouteDialogResult { class ContactRouteDialogResult {
final ParsedContactRoute? route; final ParsedContactRoute? route;
final bool shouldClear; final bool shouldClear;
final LatLng? inferredFallbackLocation;
const ContactRouteDialogResult._({this.route, required this.shouldClear}); const ContactRouteDialogResult._({
this.route,
required this.shouldClear,
this.inferredFallbackLocation,
});
const ContactRouteDialogResult.set(ParsedContactRoute route) const ContactRouteDialogResult.set(ParsedContactRoute route)
: this._(route: route, shouldClear: false); : this._(route: route, shouldClear: false);
const ContactRouteDialogResult.setWithFallback(
ParsedContactRoute route, {
LatLng? inferredFallbackLocation,
}) : this._(
route: route,
shouldClear: false,
inferredFallbackLocation: inferredFallbackLocation,
);
const ContactRouteDialogResult.clear() : this._(shouldClear: true); const ContactRouteDialogResult.clear() : this._(shouldClear: true);
} }
@@ -56,10 +77,14 @@ class ContactRouteDialog extends StatefulWidget {
class _ContactRouteDialogState extends State<ContactRouteDialog> { class _ContactRouteDialogState extends State<ContactRouteDialog> {
late final TextEditingController _controller; late final TextEditingController _controller;
final PathHistoryService _pathHistoryService = PathHistoryService();
int _selectedHashSize = RouteHashPreferences.defaultHashSize; int _selectedHashSize = RouteHashPreferences.defaultHashSize;
ParsedContactRoute? _parsedRoute; ParsedContactRoute? _parsedRoute;
String? _errorText; String? _errorText;
bool _showRoutingInfo = false; bool _showRoutingInfo = false;
List<Contact> _selectedMapHops = const [];
ContactPathHistory? _pathHistory;
_RouteEntryMode _entryMode = _RouteEntryMode.map;
@override @override
void initState() { void initState() {
@@ -68,7 +93,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
text: widget.contact.routeCanonicalText, text: widget.contact.routeCanonicalText,
); );
_controller.addListener(_reparse); _controller.addListener(_reparse);
_entryMode = widget.contact.routeCanonicalText.isNotEmpty
? _RouteEntryMode.manual
: _RouteEntryMode.map;
_loadHashSizePreference(); _loadHashSizePreference();
_loadPathHistory();
_reparse(); _reparse();
} }
@@ -86,6 +115,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
setState(() { setState(() {
_parsedRoute = null; _parsedRoute = null;
_errorText = null; _errorText = null;
_selectedMapHops = const [];
}); });
return; return;
} }
@@ -95,9 +125,11 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
input, input,
expectedHashSize: _selectedHashSize, expectedHashSize: _selectedHashSize,
); );
final selectedMapHops = _mapSelectionForText(input);
setState(() { setState(() {
_parsedRoute = parsed; _parsedRoute = parsed;
_errorText = null; _errorText = null;
_selectedMapHops = selectedMapHops;
}); });
} on ContactRouteFormatException catch (error) { } on ContactRouteFormatException catch (error) {
setState(() { setState(() {
@@ -107,6 +139,36 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
} }
} }
List<Contact> get _routeCandidates =>
widget.availableContacts
.where(
(contact) => contact.isRepeater && contact.displayLocation != null,
)
.toList()
..sort((a, b) => a.displayName.compareTo(b.displayName));
List<Contact> _mapSelectionForText(String text) {
final tokens = text
.trim()
.split(',')
.map((token) => token.trim().toUpperCase())
.where((token) => token.isNotEmpty)
.toList();
final selected = <Contact>[];
final seen = <String>{};
for (final token in tokens) {
final match = _routeCandidates
.where(
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
)
.firstOrNull;
if (match != null && seen.add(match.publicKeyHex)) {
selected.add(match);
}
}
return selected;
}
Future<void> _loadHashSizePreference() async { Future<void> _loadHashSizePreference() async {
final hashSize = await RouteHashPreferences.getHashSize(); final hashSize = await RouteHashPreferences.getHashSize();
if (!mounted) return; if (!mounted) return;
@@ -116,6 +178,16 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_reparse(); _reparse();
} }
Future<void> _loadPathHistory() async {
await _pathHistoryService.initialize();
if (!mounted) return;
setState(() {
_pathHistory = _pathHistoryService.historyFor(
widget.contact.publicKeyHex,
);
});
}
String _tokenFor(Contact contact, int hashSize) { String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase(); final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2; final length = hashSize * 2;
@@ -125,56 +197,231 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
return hex.substring(0, length); return hex.substring(0, length);
} }
void _appendHop(Contact contact) { void _syncControllerFromSelectedHops() {
final token = _tokenFor(contact, _selectedHashSize); final tokens = _selectedMapHops
final current = _controller.text.trim(); .map((contact) => _tokenFor(contact, _selectedHashSize))
_controller.text = current.isEmpty ? token : '$current,$token'; .toList();
_controller.text = tokens.join(',');
_controller.selection = TextSelection.fromPosition( _controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length), TextPosition(offset: _controller.text.length),
); );
} }
@override void _toggleHop(Contact contact) {
Widget build(BuildContext context) { setState(() {
final appProvider = context.watch<AppProvider>(); if (_selectedMapHops.any(
final routeCandidates = (item) => item.publicKeyHex == contact.publicKeyHex,
widget.availableContacts )) {
.where((contact) => contact.isRepeater || contact.isRoom) _selectedMapHops = _selectedMapHops
.toList() .where((item) => item.publicKeyHex != contact.publicKeyHex)
..sort((a, b) => a.displayName.compareTo(b.displayName)); .toList();
} else {
_selectedMapHops = [..._selectedMapHops, contact];
}
_syncControllerFromSelectedHops();
_reparse();
});
}
return FractionallySizedBox( void _applyResolvedPlan(ResolvedContactRoutePlan plan) {
heightFactor: 0.85, setState(() {
child: Padding( _selectedMapHops = plan.selectedContacts;
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), _controller.text = plan.canonicalText;
child: Column( _controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
_errorText = null;
_entryMode = _RouteEntryMode.map;
});
_reparse();
}
void _applyHistoryRecord(PathRecord record) {
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
setState(() {
_controller.text = canonicalText;
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
_errorText = null;
_entryMode = _RouteEntryMode.manual;
});
_reparse();
}
LatLng? _resolveLastHopLocation() {
if (_selectedMapHops.isNotEmpty) {
return _selectedMapHops.last.displayLocation == null
? null
: LatLng(
_selectedMapHops.last.displayLocation!.latitude,
_selectedMapHops.last.displayLocation!.longitude,
);
}
final tokens = _controller.text
.trim()
.split(',')
.map((token) => token.trim().toUpperCase())
.where((token) => token.isNotEmpty)
.toList();
if (tokens.isEmpty) return null;
final lastToken = tokens.last;
final match = _routeCandidates
.where(
(contact) => contact.publicKeyHex.toUpperCase().startsWith(lastToken),
)
.firstOrNull;
final location = match?.displayLocation;
if (location == null) return null;
return LatLng(location.latitude, location.longitude);
}
LatLng? _buildSyntheticFallbackLocation() {
final lastHopLocation = _resolveLastHopLocation();
if (lastHopLocation == null) return null;
final seed = widget.contact.publicKey.fold<int>(
_controller.text.codeUnits.fold<int>(0, (sum, unit) => sum + unit),
(sum, byte) => sum + byte,
);
final angle = (seed % 360) * (math.pi / 180.0);
const radiusMeters = 500.0;
final latOffset = (radiusMeters / 111320.0) * math.cos(angle);
final lonDenominator =
111320.0 * math.cos(lastHopLocation.latitude * (math.pi / 180.0));
final lonOffset = lonDenominator.abs() < 1e-6
? 0.0
: (radiusMeters / lonDenominator) * math.sin(angle);
return LatLng(
lastHopLocation.latitude + latOffset,
lastHopLocation.longitude + lonOffset,
);
}
void _resolvePathAutomatically() {
final connectionProvider = context.read<ConnectionProvider>();
final advLat = connectionProvider.deviceInfo.advLat;
final advLon = connectionProvider.deviceInfo.advLon;
final recipientLocation = widget.contact.displayLocation;
if (advLat == null ||
advLon == null ||
(advLat == 0 && advLon == 0) ||
recipientLocation == null) {
setState(() {
_errorText =
'Automatic resolve needs both your advertised location and the contact location.';
});
return;
}
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: LatLng(advLat / 1e6, advLon / 1e6),
recipient: widget.contact,
availableContacts: widget.availableContacts,
hashSize: _selectedHashSize,
);
if (plan == null) {
setState(() {
_errorText =
'Could not resolve a route from available repeater locations.';
});
return;
}
_applyResolvedPlan(plan);
}
String _canonicalRouteFromBytes(
List<int> pathBytes, {
required int hashSize,
}) {
final hops = <String>[];
for (var i = 0; i < pathBytes.length; i += hashSize) {
final hop = pathBytes.sublist(i, i + hashSize);
hops.add(
hop
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
String _historySubtitle(PathRecord record) {
final attempts = record.successCount + record.failureCount;
final lastSeen = MaterialLocalizations.of(
context,
).formatShortDate(record.lastUsedAt);
final sourceLabel = switch (record.source) {
PathRecordSource.observed => 'Observed on mesh',
PathRecordSource.learned => 'Learned route',
};
final successRate = attempts == 0
? 'No send stats yet'
: '${(record.successRate * 100).round()}% success over $attempts send${attempts == 1 ? '' : 's'}';
final latency = record.lastRoundTripTimeMs > 0
? '${record.lastRoundTripTimeMs} ms'
: '';
return '$sourceLabel$successRate • Last used $lastSeen$latency';
}
Widget _buildHistoryRecordTile(PathRecord record, {String? title}) {
final canonicalText = _canonicalRouteFromBytes(
record.pathBytes,
hashSize: record.hashSize,
);
return Card(
margin: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
leading: title == null ? null : const Icon(Icons.alt_route),
title: title == null
? Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 6),
Text(
canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(_historySubtitle(record)),
),
trailing: FilledButton.tonal(
onPressed: () => _applyHistoryRecord(record),
child: const Text('Use'),
),
),
);
}
Widget _buildPreviewSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(
'Set Route for ${widget.contact.displayName}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
TextField(
controller: _controller,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Route',
hintText: _selectedHashSize == 1
? 'AA,BB,CC'
: _selectedHashSize == 2
? 'AABB,CCDD'
: 'AABBCC,DDEEFF',
helperText:
'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
errorText: _errorText,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Text( Text(
_parsedRoute == null _parsedRoute == null
? 'Preview: enter a route to validate it.' ? 'Preview: enter or pick a route to validate it.'
: 'Preview: ${_parsedRoute!.summary}${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', : 'Preview: ${_parsedRoute!.summary}${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
@@ -187,6 +434,329 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
), ),
], ],
],
);
}
Widget _buildBuilderTab(
BuildContext context, {
required List<Contact> routeCandidates,
required List<LatLng> mapPoints,
required List<LatLng> routePoints,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SegmentedButton<_RouteEntryMode>(
segments: const [
ButtonSegment<_RouteEntryMode>(
value: _RouteEntryMode.map,
icon: Icon(Icons.map_outlined),
label: Text('Map'),
),
ButtonSegment<_RouteEntryMode>(
value: _RouteEntryMode.manual,
icon: Icon(Icons.tune),
label: Text('Manual'),
),
],
selected: {_entryMode},
onSelectionChanged: (selection) {
setState(() {
_entryMode = selection.first;
});
},
),
const SizedBox(height: 16),
if (_entryMode == _RouteEntryMode.manual) ...[
TextField(
controller: _controller,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Route',
hintText: _selectedHashSize == 1
? 'AA,BB,CC'
: _selectedHashSize == 2
? 'AABB,CCDD'
: 'AABBCC,DDEEFF',
helperText:
'Enter comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
errorText: _errorText,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
_buildPreviewSection(),
] else ...[
Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed: _resolvePathAutomatically,
icon: const Icon(Icons.auto_fix_high),
label: const Text('Resolve Path'),
),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Text(
'Tap repeaters on the map to build the path, then review the generated route below.',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 260,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
),
child: mapPoints.length < 2
? const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Map path builder needs your advertised location, the contact location, and visible repeater locations.',
textAlign: TextAlign.center,
),
),
)
: flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit: flutter_map.CameraFit.bounds(
bounds: flutter_map.LatLngBounds.fromPoints(
mapPoints,
),
padding: const EdgeInsets.all(32),
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
if (routePoints.length >= 2)
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: routePoints,
strokeWidth: 4,
color: Theme.of(context).colorScheme.primary,
),
],
),
flutter_map.MarkerLayer(
markers: [
...routeCandidates.map((candidate) {
final isSelected = _selectedMapHops.any(
(item) =>
item.publicKeyHex ==
candidate.publicKeyHex,
);
return flutter_map.Marker(
point: LatLng(
candidate.displayLocation!.latitude,
candidate.displayLocation!.longitude,
),
width: 64,
height: 70,
child: GestureDetector(
onTap: () => _toggleHop(candidate),
child: _RouteMarkerDot(
label: _tokenFor(
candidate,
_selectedHashSize,
),
color: isSelected
? Theme.of(
context,
).colorScheme.primary
: Colors.blueGrey,
),
),
);
}),
],
),
],
),
),
),
),
const SizedBox(height: 12),
if (_selectedMapHops.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: _selectedMapHops.map((contact) {
return InputChip(
label: Text(contact.displayName),
onDeleted: () => _toggleHop(contact),
);
}).toList(),
),
const SizedBox(height: 12),
TextField(
controller: _controller,
readOnly: true,
decoration: InputDecoration(
labelText: 'Generated route',
helperText: 'Switch to Manual if you want to edit the hop list.',
errorText: _errorText,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
_buildPreviewSection(),
],
],
);
}
Widget _buildHistoryTab() {
final records = List<PathRecord>.from(_pathHistory?.directPaths ?? const [])
..sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
if (records.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No historical paths for this contact yet.',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
),
);
}
PathRecord? observedRecord;
for (final record in records) {
if (record.source == PathRecordSource.observed) {
observedRecord = record;
break;
}
}
final remainingRecords = observedRecord == null
? records
: records
.where((record) => !identical(record, observedRecord))
.toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (observedRecord != null) ...[
_buildHistoryRecordTile(observedRecord, title: 'Observed mesh route'),
const SizedBox(height: 16),
],
if (remainingRecords.isEmpty)
Text(
observedRecord == null
? 'No additional route history yet.'
: 'Observed routes you start using will continue to build history here.',
style: Theme.of(context).textTheme.bodyMedium,
)
else
ListView.separated(
itemCount: remainingRecords.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _buildHistoryRecordTile(remainingRecords[index]);
},
),
],
);
}
@override
Widget build(BuildContext context) {
final appProvider = context.watch<AppProvider>();
final routeCandidates = _routeCandidates;
final connectionProvider = context.watch<ConnectionProvider>();
final selfPoint =
connectionProvider.deviceInfo.advLat != null &&
connectionProvider.deviceInfo.advLon != null &&
!(connectionProvider.deviceInfo.advLat == 0 &&
connectionProvider.deviceInfo.advLon == 0)
? LatLng(
connectionProvider.deviceInfo.advLat! / 1e6,
connectionProvider.deviceInfo.advLon! / 1e6,
)
: null;
final recipientLocation = widget.contact.displayLocation;
final recipientPoint = recipientLocation == null
? null
: LatLng(recipientLocation.latitude, recipientLocation.longitude);
final routePoints = <LatLng>[
...?selfPoint == null ? null : [selfPoint],
..._selectedMapHops
.where((contact) => contact.displayLocation != null)
.map(
(contact) => LatLng(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
...?recipientPoint == null ? null : [recipientPoint],
];
final mapPoints = <LatLng>[
...?selfPoint == null ? null : [selfPoint],
...?recipientPoint == null ? null : [recipientPoint],
...routeCandidates.map(
(contact) => LatLng(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
];
return DefaultTabController(
length: 2,
child: FractionallySizedBox(
heightFactor: 0.85,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Set Route for ${widget.contact.displayName}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Choose how to build the route, or reuse one from history.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
const TabBar(
tabs: [
Tab(text: 'Build'),
Tab(text: 'History'),
],
),
const SizedBox(height: 16),
Expanded(
child: TabBarView(
children: [
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBuilderTab(
context,
routeCandidates: routeCandidates,
mapPoints: mapPoints,
routePoints: routePoints,
),
const SizedBox(height: 16), const SizedBox(height: 16),
_AutomationRoutingInfo( _AutomationRoutingInfo(
isExpanded: _showRoutingInfo, isExpanded: _showRoutingInfo,
@@ -195,43 +765,24 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
_showRoutingInfo = !_showRoutingInfo; _showRoutingInfo = !_showRoutingInfo;
}); });
}, },
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled, autoRouteRotationEnabled:
appProvider.autoRouteRotationEnabled,
nearestRelayFallbackEnabled: nearestRelayFallbackEnabled:
appProvider.nearestRelayFallbackEnabled, appProvider.nearestRelayFallbackEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry, clearPathOnMaxRetry:
appProvider.clearPathOnMaxRetry,
), ),
const SizedBox(height: 16), ],
Text(
'Pick hops from contacts',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
if (routeCandidates.isEmpty)
const Text(
'No repeater or room contacts are available for route building.',
)
else
Expanded(
child: ListView.builder(
itemCount: routeCandidates.length,
itemBuilder: (context, index) {
final candidate = routeCandidates[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(candidate.displayName),
trailing: TextButton(
onPressed: () => _appendHop(candidate),
child: Text(
'Use ${_tokenFor(candidate, _selectedHashSize)}',
), ),
), ),
); SingleChildScrollView(child: _buildHistoryTab()),
}, ],
), ),
), ),
const SizedBox(height: 16), OverflowBar(
Row( alignment: MainAxisAlignment.spaceBetween,
spacing: 8,
overflowSpacing: 8,
children: [ children: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
@@ -244,13 +795,16 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
).pop(const ContactRouteDialogResult.clear()), ).pop(const ContactRouteDialogResult.clear()),
child: const Text('Clear Route'), child: const Text('Clear Route'),
), ),
const Spacer(),
FilledButton( FilledButton(
onPressed: _parsedRoute == null onPressed: _parsedRoute == null
? null ? null
: () => Navigator.of( : () => Navigator.of(context).pop(
context, ContactRouteDialogResult.setWithFallback(
).pop(ContactRouteDialogResult.set(_parsedRoute!)), _parsedRoute!,
inferredFallbackLocation:
_buildSyntheticFallbackLocation(),
),
),
child: const Text('Set Route'), child: const Text('Set Route'),
), ),
], ],
@@ -258,6 +812,34 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
], ],
), ),
), ),
),
);
}
}
enum _RouteEntryMode { map, manual }
class _RouteMarkerDot extends StatelessWidget {
final String label;
final Color color;
const _RouteMarkerDot({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Tooltip(
message: label,
child: Center(
child: Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
),
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,10 @@
import 'package:flutter/material.dart'; import 'package: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

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

View File

@@ -27,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart';
import '../../utils/location_formats.dart'; import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../../utils/log_rx_route_decoder.dart';
import '../../models/message_transfer_details.dart'; import '../../models/message_transfer_details.dart';
import 'voice_message_bubble.dart'; import 'voice_message_bubble.dart';
import 'image_message_bubble.dart'; import 'image_message_bubble.dart';
@@ -67,6 +68,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;
@@ -94,6 +96,74 @@ class _MessageBubbleState extends State<MessageBubble> {
}); });
} }
Widget _buildMessageTextContent(String text, TextStyle? baseBodyStyle) {
final matches = _mentionPattern.allMatches(text).toList();
if (matches.isEmpty) {
return Text(text, style: baseBodyStyle);
}
final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
final backgroundColor = Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12);
final borderColor = Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.25);
final spans = <InlineSpan>[];
var cursor = 0;
for (final match in matches) {
if (match.start > cursor) {
spans.add(
TextSpan(
text: text.substring(cursor, match.start),
style: baseBodyStyle,
),
);
}
final mentionName = match.group(1)?.trim() ?? '';
if (mentionName.isNotEmpty) {
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: borderColor),
),
child: Text(
'@$mentionName',
style: baseBodyStyle?.copyWith(
color: textColor,
fontSize: mentionFontSize,
fontWeight: FontWeight.w600,
height: 1.0,
),
),
),
),
);
} else {
spans.add(TextSpan(text: match.group(0), style: baseBodyStyle));
}
cursor = match.end;
}
if (cursor < text.length) {
spans.add(TextSpan(text: text.substring(cursor), style: baseBodyStyle));
}
return Text.rich(TextSpan(children: spans, style: baseBodyStyle));
}
void _handleBubbleTap({required bool isSarMarker, required bool isDrawing}) { void _handleBubbleTap({required bool isSarMarker, required bool isDrawing}) {
if (!widget.message.isRead && if (!widget.message.isRead &&
!widget.message.isSentMessage && !widget.message.isSentMessage &&
@@ -1269,12 +1339,10 @@ class _MessageBubbleState extends State<MessageBubble> {
// Logged frame format: // Logged frame format:
// [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen // [0]=response code 0x88, [1]=snrRaw, [2]=rssi, [3]=packet header, [4]=pathLen
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
final payloadType = (raw[3] >> 2) & 0x0F; if (decoded == null) continue;
final pathLen = raw[4]; if (decoded.payloadType != expectedPayloadType) continue;
if (payloadType != expectedPayloadType) continue; if (decoded.hopCount != message.pathLen) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs = final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -1290,11 +1358,9 @@ class _MessageBubbleState extends State<MessageBubble> {
List<int>? _extractPathBytesFromLog(BlePacketLog? log) { List<int>? _extractPathBytesFromLog(BlePacketLog? log) {
if (log == null) return null; if (log == null) return null;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
if (raw.length < 6) return null; if (decoded == null || decoded.pathBytes.isEmpty) return null;
final pathLen = raw[4]; return decoded.pathBytes;
if (pathLen <= 0 || raw.length < 5 + pathLen) return null;
return raw.sublist(5, 5 + pathLen);
} }
void _showDeleteConfirmation(BuildContext context) { void _showDeleteConfirmation(BuildContext context) {
@@ -1724,6 +1790,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>();
@@ -2007,9 +2074,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,
@@ -2232,7 +2301,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;
@@ -2318,7 +2390,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 &&
@@ -2448,7 +2520,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'
@@ -2456,6 +2528,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;
@@ -2464,7 +2545,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;
@@ -2659,6 +2739,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

@@ -1,6 +1,7 @@
// ignore_for_file: use_null_aware_elements // ignore_for_file: use_null_aware_elements
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -9,9 +10,11 @@ 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;
@@ -34,8 +37,14 @@ 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( final storedPath = messagesProvider
.getMessageReceptionDetails(widget.message.id)
?.pathBytes;
final packetPath = (storedPath != null && storedPath.isNotEmpty)
? storedPath
: _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs, logs: connectionProvider.bleService.packetLogs,
message: widget.message, message: widget.message,
); );
@@ -46,8 +55,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
: _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,
@@ -60,11 +71,18 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
return trace; return trace;
} }
final remoteNodes = await MeshMapNodesService.fetchNodes( unawaited(
MeshMapNodesService.syncInBackgroundIfStale(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
);
final remoteNodes = await MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl, cacheTtl: MeshMapNodesService.traceCacheTtl,
); );
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,
@@ -100,8 +118,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final trace = snapshot.data!; final trace = 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))
@@ -275,7 +293,7 @@ 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}'}', '${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}${entry.value.matchSummary == null ? '' : '${entry.value.matchSummary}'}',
), ),
), ),
), ),
@@ -319,7 +337,10 @@ 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) {
@@ -332,13 +353,17 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) { List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
final pathNodes = trace.matchedPathNodes.whereType<MeshMapNode>().toList(); final pathNodes = trace.matchedPathNodes
.map((entry) => entry.node)
.whereType<MeshMapNode>()
.toList();
if (pathNodes.isEmpty) { if (pathNodes.isEmpty) {
return [ return [
if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!), if (trace.sender.node != null)
if (trace.recipient != null && _RouteDisplayEntry.fromResolved(trace.sender),
trace.recipient!.publicKey != trace.sender?.publicKey) if (trace.recipient.node != null &&
_RouteDisplayEntry.fromNode(trace.recipient!), trace.recipient.node!.publicKey != trace.sender.node?.publicKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
]; ];
} }
@@ -346,29 +371,34 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) { final entries = 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,
); );
}).toList(); }).toList();
final lastKey = pathNodes.last.publicKey; final lastKey = pathNodes.last.publicKey;
return [ return [
...entries, ...entries,
if (trace.recipient != null && trace.recipient!.publicKey != lastKey) if (trace.recipient.node != null &&
_RouteDisplayEntry.fromNode(trace.recipient!), trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
]; ];
} }
final firstKey = pathNodes.first.publicKey; final firstKey = pathNodes.first.publicKey;
final lastKey = pathNodes.last.publicKey; final lastKey = pathNodes.last.publicKey;
return [ return [
if (trace.sender != null && trace.sender!.publicKey != firstKey) if (trace.sender.node != null && trace.sender.node!.publicKey != firstKey)
_RouteDisplayEntry.fromNode(trace.sender!), _RouteDisplayEntry.fromResolved(trace.sender),
...pathNodes.map(_RouteDisplayEntry.fromNode), ...trace.matchedPathNodes
if (trace.recipient != null && trace.recipient!.publicKey != lastKey) .where((entry) => entry.node != null)
_RouteDisplayEntry.fromNode(trace.recipient!), .map(_RouteDisplayEntry.fromResolved),
if (trace.recipient.node != null &&
trace.recipient.node!.publicKey != lastKey)
_RouteDisplayEntry.fromResolved(trace.recipient),
]; ];
} }
@@ -391,14 +421,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) {
@@ -433,13 +455,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(
@@ -449,12 +486,15 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final hopHashes = LogRxRouteDecoder.splitHopHashes( final hopHashes = LogRxRouteDecoder.splitHopHashes(
packetPath, packetPath,
hashSize: hashSize, hashSize: hashSize,
); ).reversed.toList();
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,
); );
return _TraceResult( return _TraceResult(
mode: TraceMode.packetPath, mode: TraceMode.packetPath,
@@ -467,14 +507,20 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
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(
node: node,
matchCount: 1,
usedOnlineFallback: false,
),
),
if (recipientNode.node != null) recipientNode,
]; ];
return _TraceResult( return _TraceResult(
@@ -487,16 +533,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;
@@ -516,13 +563,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
for (final log in logs) { for (final log in logs) {
if (log.responseCode != 0x88) continue; // pushLogRxData if (log.responseCode != 0x88) continue; // pushLogRxData
if (log.rawData.length < 6) continue; if (log.rawData.length < 6) continue;
final raw = log.rawData; final decoded = LogRxRouteDecoder.decode(log.rawData);
final header = raw[3]; if (decoded == null) continue;
final payloadType = (header >> 2) & 0x0F; if (decoded.payloadType != expectedPayloadType) continue;
final pathLen = raw[4]; if (decoded.hopCount != message.pathLen) continue;
if (payloadType != expectedPayloadType) continue;
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs = final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs(); (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
@@ -533,38 +577,37 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
} }
if (bestLog == null || bestDeltaMs > 30000) return null; if (bestLog == null || bestDeltaMs > 30000) return null;
final raw = bestLog.rawData; final decoded = LogRxRouteDecoder.decode(bestLog.rawData);
final pathLen = raw[4]; if (decoded == null || decoded.pathBytes.isEmpty) {
return raw.sublist(5, 5 + pathLen); return null;
}
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;
} }
@@ -633,10 +676,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,
@@ -648,24 +691,30 @@ class _TraceResult {
} }
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;
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,
}); });
factory _RouteDisplayEntry.fromNode(MeshMapNode node) { MeshMapNode? get node => resolved.node;
factory _RouteDisplayEntry.fromResolved(ResolvedTraceNode resolved) {
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,
); );
} }
} }

View File

@@ -366,15 +366,6 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
} }
} }
if (!sender.routeSupportsLegacyRawTransport) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
);
return;
}
if (sender.routeHopCount >= 2) { if (sender.routeHopCount >= 2) {
_showToast( _showToast(
'Voice fetch over ${sender.routeHopCount} hops may take a while.', 'Voice fetch over ${sender.routeHopCount} hops may take a while.',
@@ -415,10 +406,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
missingIndices: missing, missingIndices: missing,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
) )
: VoiceFetchRequest( : VoiceFetchRequest(sessionId: sessionId, requesterKey6: requesterKey6);
sessionId: sessionId,
requesterKey6: requesterKey6,
);
try { try {
debugPrint( debugPrint(
@@ -426,7 +414,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
); );
await connectionProvider.sendRawVoicePacket( await connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath, contactPath: sender.outPath,
contactPathLen: sender.routeSignedPathLen, contactPathLen: sender.routeEncodedPathLen,
payload: request.encodeBinary(), payload: request.encodeBinary(),
); );
} catch (_) { } catch (_) {

View File

@@ -796,7 +796,7 @@ packages:
description: description:
path: "." path: "."
ref: main ref: main
resolved-ref: cea66b5251135c7f9b84f15c0878d4c8af6e88e9 resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460
url: "https://github.com/dz0ny/meshcore_client.git" url: "https://github.com/dz0ny/meshcore_client.git"
source: git source: git
version: "0.1.0" version: "0.1.0"
@@ -1265,10 +1265,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqflite_android name: sqflite_android
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 sha256: "881e28efdcc9950fd8e9bb42713dcf1103e62a2e7168f23c9338d82db13dec40"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.2+2" version: "2.4.2+3"
sqflite_common: sqflite_common:
dependency: transitive dependency: transitive
description: description:

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 2026.0308.3+23 version: 2026.0312.1+27
environment: environment:
sdk: ^3.9.2 sdk: ^3.9.2

View File

@@ -104,7 +104,7 @@ void main() {
expect(contact.routeHashSize, 3); expect(contact.routeHashSize, 3);
expect(contact.routeHopCount, 2); expect(contact.routeHopCount, 2);
expect(contact.routeCanonicalText, 'AABBCC,DDEEFF'); expect(contact.routeCanonicalText, 'AABBCC,DDEEFF');
expect(contact.routeSupportsLegacyRawTransport, isFalse); expect(contact.routeSupportsLegacyRawTransport, isTrue);
}); });
test('treats -1 as unknown route', () { test('treats -1 as unknown route', () {

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.dart'; import 'package:meshcore_sar_app/models/contact.dart';
@@ -457,6 +458,24 @@ void main() {
expect(updated.routeCanonicalText, 'AABB,CCDD'); expect(updated.routeCanonicalText, 'AABB,CCDD');
}); });
test('stores inferred fallback gps when route is set locally', () {
final route = ContactRouteCodec.parse('AABB,CCDD');
const fallback = LatLng(46.1001, 14.5002);
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
inferredFallbackLocation: fallback,
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(46.1001, 0.000001));
expect(updated.displayLocation!.longitude, closeTo(14.5002, 0.000001));
expect(updated.advertHistory, isNotEmpty);
});
test('resetContactRouteLocal clears route state', () { test('resetContactRouteLocal clears route state', () {
final route = ContactRouteCodec.parse('AA,BB,CC'); final route = ContactRouteCodec.parse('AA,BB,CC');
provider.setContactRouteLocal( provider.setContactRouteLocal(
@@ -471,6 +490,153 @@ void main() {
expect(updated.routeHasPath, isFalse); expect(updated.routeHasPath, isFalse);
expect(updated.routeSummary, 'Flood/Unknown'); expect(updated.routeSummary, 'Flood/Unknown');
}); });
test('retains existing route when contact refresh omits path', () {
final retainedRoute = ContactRouteCodec.parse('AABB,CCDD');
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
paddedPathBytes: retainedRoute.paddedPathBytes,
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
name: 'Routey',
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.routeHasPath, isTrue);
expect(updated.routeCanonicalText, 'AABB,CCDD');
});
test('applies retained pending advert route when contact is resolved', () {
final pendingKey = createPublicKey(96);
final retainedRoute = ContactRouteCodec.parse('1122,3344');
provider.retainReceivedRoute(
pendingKey,
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
paddedPathBytes: retainedRoute.paddedPathBytes,
);
provider.addPendingAdvert(pendingKey);
provider.addOrUpdateContact(
createContact(
key: pendingKey,
type: ContactType.chat,
name: 'Pending Routey',
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
);
final updated = provider.findContactByKey(pendingKey)!;
expect(updated.routeHasPath, isTrue);
expect(updated.routeCanonicalText, '1122,3344');
expect(
provider.pendingAdverts.where(
(advert) => advert.publicKeyHex == updated.publicKeyHex,
),
isEmpty,
);
});
test('infers a fallback location 100m from last-hop repeater', () {
final repeaterKey = Uint8List.fromList([
0xCC,
0xDD,
0x10,
0x11,
0x12,
0x13,
...List<int>.generate(26, (index) => index + 20),
]);
provider.addOrUpdateContact(
createContact(
key: repeaterKey,
type: ContactType.repeater,
name: 'Relay Alpha',
),
);
final targetKey = createPublicKey(120);
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.addOrUpdateContact(
Contact(
publicKey: targetKey,
type: ContactType.chat,
flags: 0,
outPathLen: route.signedEncodedPathLen,
outPath: route.paddedPathBytes,
advName: 'No GPS',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(targetKey)!;
final inferred = updated.displayLocation;
final repeater = provider.findContactByKey(repeaterKey)!;
final repeaterLocation = repeater.displayLocation;
expect(inferred, isNotNull);
expect(repeaterLocation, isNotNull);
final distanceMeters = Geolocator.distanceBetween(
repeaterLocation!.latitude,
repeaterLocation.longitude,
inferred!.latitude,
inferred.longitude,
);
expect(distanceMeters, closeTo(100.0, 8.0));
});
test(
'does not infer a fallback location when the contact advertises one',
() {
final repeaterKey = Uint8List.fromList([
0xCC,
0xDD,
0x10,
0x11,
0x12,
0x13,
...List<int>.generate(26, (index) => index + 20),
]);
provider.addOrUpdateContact(
createContact(
key: repeaterKey,
type: ContactType.repeater,
name: 'Relay Alpha',
),
);
final targetKey = createPublicKey(121);
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.addOrUpdateContact(
Contact(
publicKey: targetKey,
type: ContactType.chat,
flags: 0,
outPathLen: route.signedEncodedPathLen,
outPath: route.paddedPathBytes,
advName: 'Has Advert',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (45.1234 * 1e6).toInt(),
advLon: (13.8765 * 1e6).toInt(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(targetKey)!;
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001));
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001));
},
);
}); });
group('ContactsProvider.updateFastGps', () { group('ContactsProvider.updateFastGps', () {
@@ -533,4 +699,39 @@ void main() {
expect(after.advLon, equals(before.advLon)); expect(after.advLon, equals(before.advLon));
}); });
}); });
group('ContactsProvider saved contact groups', () {
late ContactsProvider provider;
setUp(() {
SharedPreferences.setMockInitialValues({});
provider = ContactsProvider();
});
test('adds and removes saved groups by filter', () async {
expect(provider.savedContactGroups, isEmpty);
await provider.addSavedGroupForFilter('teamMembers', 'alpha');
expect(provider.savedContactGroups, hasLength(1));
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isTrue);
expect(provider.hasSavedGroupForFilter('teamMembers', 'ALPHA'), isTrue);
await provider.removeSavedGroupForFilter('teamMembers', 'ALPHA');
expect(provider.savedContactGroups, isEmpty);
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isFalse);
});
test('loads persisted saved groups during initialization', () async {
await provider.addSavedGroupForFilter('rooms', 'ops');
final restored = ContactsProvider();
await restored.initializeEarly();
expect(restored.savedGroupsForSection('rooms'), hasLength(1));
expect(restored.savedGroupsForSection('rooms').first.query, 'ops');
expect(restored.savedGroupsForSection('rooms').first.label, 'ops');
});
});
} }

View File

@@ -47,6 +47,63 @@ void main() {
expect(ok, isFalse); expect(ok, isFalse);
}); });
test('returns false when requester has no learned path', () async {
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: _buildContact(outPathLen: -1),
fragments: [
_Fragment(0, Uint8List.fromList([1])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {},
);
expect(ok, isFalse);
});
test('returns false when requester path payload is empty', () async {
final requester = Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (i) => i)),
type: ContactType.chat,
flags: 0,
outPathLen: 1,
outPath: Uint8List(0),
advName: 'Requester',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
final ok = await serveCachedSessionFragments<_Fragment>(
providerLabel: 'TestProvider',
sessionId: 'deadbeef',
requester: requester,
fragments: [
_Fragment(0, Uint8List.fromList([1])),
],
maxDirectPayloadHops: 3,
indexOf: (f) => f.index,
encodeBinary: (f) => f.payload,
sendRawPacket:
({
required contactPath,
required contactPathLen,
required payload,
}) async {},
);
expect(ok, isFalse);
});
test('sends only requested indices', () async { test('sends only requested indices', () async {
final sent = <Uint8List>[]; final sent = <Uint8List>[];
final ok = await serveCachedSessionFragments<_Fragment>( final ok = await serveCachedSessionFragments<_Fragment>(

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/message.dart'; import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/models/message_contact_location.dart'; import 'package:meshcore_sar_app/models/message_contact_location.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart'; import 'package:meshcore_sar_app/providers/messages_provider.dart';
@@ -11,6 +12,30 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
Contact buildContact({
required List<int> prefix,
required ContactType type,
String name = 'Test Contact',
}) {
final key = Uint8List.fromList([
...prefix,
...List<int>.generate(26, (index) => index),
]);
return Contact(
publicKey: key,
type: type,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
group('MessagesProvider voice detection', () { group('MessagesProvider voice detection', () {
setUp(() { setUp(() {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
@@ -190,5 +215,60 @@ void main() {
expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566'); expect(restoredVoice?.downloaders.single.requesterKey6, '112233445566');
expect(restoredImage?.downloaders.single.requesterName, equals('Bob')); expect(restoredImage?.downloaders.single.requesterName, equals('Bob'));
}); });
test('marks only the selected contact destination as read', () {
final provider = MessagesProvider();
final alice = buildContact(
prefix: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15],
type: ContactType.chat,
name: 'Alice',
);
final bob = buildContact(
prefix: [0x20, 0x21, 0x22, 0x23, 0x24, 0x25],
type: ContactType.chat,
name: 'Bob',
);
provider.addMessage(
Message(
id: 'alice-incoming',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000100,
text: 'Alice unread',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: alice.publicKey.sublist(0, 6),
),
);
provider.addMessage(
Message(
id: 'bob-incoming',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000101,
text: 'Bob unread',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: bob.publicKey.sublist(0, 6),
),
);
provider.markDestinationAsRead(
destinationType: 'contact',
contact: alice,
);
final aliceMessage = provider.messages.firstWhere(
(message) => message.id == 'alice-incoming',
);
final bobMessage = provider.messages.firstWhere(
(message) => message.id == 'bob-incoming',
);
expect(aliceMessage.isRead, isTrue);
expect(bobMessage.isRead, isFalse);
expect(provider.unreadCount, equals(1));
});
}); });
} }

View File

@@ -0,0 +1,179 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/screens/live_traffic_screen.dart';
BlePacketLog _log({
required DateTime timestamp,
required PacketDirection direction,
required List<int> rawData,
int? responseCode,
double? snrDb,
int? rssiDbm,
}) {
return BlePacketLog(
timestamp: timestamp,
rawData: Uint8List.fromList(rawData),
direction: direction,
responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first),
logRxDataInfo: snrDb == null && rssiDbm == null
? null
: LogRxDataInfo(
entropy: 0,
isLikelyEncrypted: false,
snrDb: snrDb,
rssiDbm: rssiDbm,
),
);
}
List<int> _multiHopRaw({
required List<int> hops,
int payloadType = 0x01,
int hashSize = 2,
}) {
final hopCount = hops.length ~/ hashSize;
final pathDescriptor = ((hashSize - 1) << 6) | hopCount;
return [
0x88,
0x00,
0x00,
payloadType << 2,
0x00,
0x00,
0x00,
0x00,
pathDescriptor,
...hops,
];
}
void main() {
testWidgets('shows empty state before traffic arrives', (tester) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
expect(find.text('No live traffic yet'), findsOneWidget);
expect(find.text('Quiet'), findsOneWidget);
});
testWidgets('updates summary and stream for incoming live traffic', (
tester,
) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
logReader: () => logs,
rxCountReader: () => 7,
refreshListenable: refresh,
now: () => now,
),
),
);
logs.addAll([
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]),
responseCode: 0x88,
snrDb: 13.5,
rssiDbm: -84,
),
_log(
timestamp: now.subtract(const Duration(seconds: 3)),
direction: PacketDirection.tx,
rawData: [0x05, 0x01, 0x02],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 2)),
direction: PacketDirection.rx,
rawData: [0x05, 0x01, 0x02],
responseCode: 0x05,
),
]);
refresh.value += 1;
await tester.pump();
expect(find.text('1 pkt/min'), findsOneWidget);
expect(find.text('Device total 7'), findsOneWidget);
expect(find.textContaining('RESP'), findsOneWidget);
expect(find.text('MULTI-HOP'), findsOneWidget);
expect(find.textContaining('RSSI -84 dBm'), findsOneWidget);
});
testWidgets('clear live view only resets transient screen state', (
tester,
) async {
final logs = <BlePacketLog>[];
final refresh = ValueNotifier<int>(0);
DateTime now = DateTime(2026, 3, 12, 12, 0, 0);
logs.add(
_log(
timestamp: now.subtract(const Duration(seconds: 4)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01]),
responseCode: 0x88,
),
);
await tester.pumpWidget(
MaterialApp(
home: LiveTrafficScreen(
logReader: () => logs,
refreshListenable: refresh,
now: () => now,
),
),
);
expect(find.text('MULTI-HOP'), findsOneWidget);
await tester.tap(find.byTooltip('Clear live view'));
await tester.pump();
expect(find.text('No live traffic yet'), findsOneWidget);
now = now.add(const Duration(seconds: 2));
logs.add(
_log(
timestamp: now,
direction: PacketDirection.tx,
rawData: [0x03, 0x04],
responseCode: 0x88,
),
);
logs.add(
_log(
timestamp: now,
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
);
refresh.value += 1;
await tester.pump();
expect(find.text('No live traffic yet'), findsNothing);
expect(find.textContaining('3 bytes'), findsOneWidget);
});
}

View File

@@ -0,0 +1,143 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/services/contact_route_resolver.dart';
void main() {
test('prefers known repeater route when available', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final routedRepeater = _contact(
name: 'Routed',
type: ContactType.repeater,
seed: 10,
lat: 46.15,
lon: 14.60,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [routedRepeater],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.tokens, [
'AA',
routedRepeater.publicKeyHex.substring(0, 2).toUpperCase(),
]);
});
test('falls back to location-only repeaters', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final nearRepeater = _contact(
name: 'Near',
type: ContactType.repeater,
seed: 10,
lat: 46.08,
lon: 14.55,
);
final farRepeater = _contact(
name: 'Far',
type: ContactType.repeater,
seed: 11,
lat: 46.40,
lon: 15.10,
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [nearRepeater, farRepeater],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.selectedContacts.first.displayName, 'Near');
});
test('for known routes prefers anchor closest to the chain end', () {
final recipient = _contact(
name: 'Target',
type: ContactType.chat,
seed: 90,
lat: 46.20,
lon: 14.70,
);
final chainHop = _contact(
name: 'Chain Hop',
type: ContactType.repeater,
seed: 0xAA,
lat: 46.01,
lon: 14.51,
);
final nearAnchor = _contact(
name: 'Near Anchor',
type: ContactType.repeater,
seed: 10,
lat: 46.03,
lon: 14.53,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final farAnchor = _contact(
name: 'Far Anchor',
type: ContactType.repeater,
seed: 11,
lat: 46.18,
lon: 14.68,
signedPathLen: ContactRouteCodec.toSignedDescriptor(1),
outPath: Uint8List.fromList([0xAA]),
);
final plan = ContactRouteResolver.resolveAutomaticRoute(
senderLocation: const LatLng(46.00, 14.50),
recipient: recipient,
availableContacts: [chainHop, nearAnchor, farAnchor],
hashSize: 1,
);
expect(plan, isNotNull);
expect(plan!.selectedContacts.last.displayName, 'Near Anchor');
});
}
Contact _contact({
required String name,
required ContactType type,
required int seed,
required double lat,
required double lon,
int signedPathLen = -1,
Uint8List? outPath,
}) {
final publicKey = Uint8List(32)..fillRange(0, 32, seed);
return Contact(
publicKey: publicKey,
type: type,
flags: 0,
outPathLen: signedPathLen,
outPath: outPath ?? Uint8List(64),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (lat * 1e6).round(),
advLon: (lon * 1e6).round(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}

View File

@@ -0,0 +1,147 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/ble_packet_log.dart';
import 'package:meshcore_sar_app/services/live_traffic_summary.dart';
BlePacketLog _log({
required DateTime timestamp,
required PacketDirection direction,
required List<int> rawData,
int? responseCode,
double? snrDb,
int? rssiDbm,
}) {
return BlePacketLog(
timestamp: timestamp,
rawData: Uint8List.fromList(rawData),
direction: direction,
responseCode: responseCode ?? (rawData.isEmpty ? null : rawData.first),
logRxDataInfo: snrDb == null && rssiDbm == null
? null
: LogRxDataInfo(
entropy: 0,
isLikelyEncrypted: false,
snrDb: snrDb,
rssiDbm: rssiDbm,
),
);
}
List<int> _multiHopRaw({
required List<int> hops,
int payloadType = 0x01,
int hashSize = 2,
}) {
final hopCount = hops.length ~/ hashSize;
final pathDescriptor = ((hashSize - 1) << 6) | hopCount;
return [
0x88,
0x00,
0x00,
payloadType << 2,
0x00,
0x00,
0x00,
0x00,
pathDescriptor,
...hops,
];
}
void main() {
group('LiveTrafficSummary', () {
test('uses only the rolling 60-second window', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final snapshot = LiveTrafficSummary.fromLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 61)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 20)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.tx,
rawData: [0x01, 0x02],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 5)),
direction: PacketDirection.rx,
rawData: [0x01, 0x02],
responseCode: 0x01,
),
], now: now);
expect(snapshot.totalCount, 1);
expect(snapshot.rxCount, 1);
expect(snapshot.txCount, 0);
expect(snapshot.packetsPerMinute, 1);
});
test('aggregates RSSI, SNR, and multi-hop route metrics', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final snapshot = LiveTrafficSummary.fromLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 30)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xC0, 0x10, 0x63, 0x01, 0x68, 0xD9]),
responseCode: 0x88,
snrDb: 12.0,
rssiDbm: -84,
),
_log(
timestamp: now.subtract(const Duration(seconds: 15)),
direction: PacketDirection.rx,
rawData: _multiHopRaw(hops: [0xDE, 0xAD, 0xBE, 0xEF]),
responseCode: 0x88,
snrDb: 6.0,
rssiDbm: -90,
),
_log(
timestamp: now.subtract(const Duration(seconds: 5)),
direction: PacketDirection.tx,
rawData: [0x03, 0x04],
responseCode: 0x88,
),
], now: now);
expect(snapshot.latestRssiDbm, -90);
expect(snapshot.latestSnrDb, 6.0);
expect(snapshot.avgRssiDbm, closeTo(-87.0, 0.01));
expect(snapshot.avgSnrDb, closeTo(9.0, 0.01));
expect(snapshot.multiHopCount, 2);
expect(snapshot.avgHopCount, closeTo(2.5, 0.01));
expect(snapshot.busyness, LiveTrafficBusyness.quiet);
});
test('supports clearing the live view without mutating source logs', () {
final now = DateTime(2026, 3, 12, 12, 0, 0);
final clearAt = now.subtract(const Duration(seconds: 8));
final snapshot = LiveTrafficSummary.fromLogs([
_log(
timestamp: now.subtract(const Duration(seconds: 10)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
_log(
timestamp: now.subtract(const Duration(seconds: 4)),
direction: PacketDirection.rx,
rawData: [0x88, 0x00, 0x00],
responseCode: 0x88,
),
], now: now, clearedAt: clearAt);
expect(snapshot.totalCount, 1);
expect(snapshot.visibleEntries, hasLength(1));
});
});
}

View File

@@ -0,0 +1,97 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:meshcore_sar_app/services/mesh_map_nodes_service.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
await MeshMapNodesService.clearCache();
});
test('fetchNodes persists nodes and cached lookup works offline', () async {
final client = MockClient((request) async {
expect(
request.url.toString(),
'https://api.meshcore.nz/api/v1/map/nodes',
);
return http.Response(
jsonEncode({
'nodes': [
{
'type': 1,
'name': 'Alpha',
'public_key': 'aa11bb22',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
],
}),
200,
);
});
final nodes = await MeshMapNodesService.fetchNodes(client: client);
final cached = await MeshMapNodesService.fetchNodes(allowNetwork: false);
expect(nodes, hasLength(1));
expect(cached, hasLength(1));
expect(cached.first.name, 'Alpha');
expect(await MeshMapNodesService.hasFreshCache(), isTrue);
});
test('loadCachedNodes ignores stale persisted cache', () async {
SharedPreferences.setMockInitialValues({
'mesh_map_nodes_cache_v1': jsonEncode([
{
'type': 1,
'name': 'Old node',
'public_key': 'bb22cc33',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
]),
'mesh_map_nodes_cache_timestamp_v1': DateTime.now()
.subtract(const Duration(hours: 25))
.millisecondsSinceEpoch,
});
final nodes = await MeshMapNodesService.loadCachedNodes();
expect(nodes, isEmpty);
expect(await MeshMapNodesService.hasFreshCache(), isFalse);
});
test('clearCache removes persisted online trace database', () async {
final client = MockClient(
(_) async => http.Response(
jsonEncode({
'nodes': [
{
'type': 1,
'name': 'Alpha',
'public_key': 'aa11bb22',
'latitude': 46.05,
'longitude': 14.50,
'updated_at': 123456,
},
],
}),
200,
),
);
await MeshMapNodesService.fetchNodes(client: client);
await MeshMapNodesService.clearCache();
expect(await MeshMapNodesService.loadCachedNodes(), isEmpty);
expect(await MeshMapNodesService.cachedAt(), isNull);
});
}

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