mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-13 09:20:29 +00:00
Compare commits
43 Commits
v2026.0308
...
v2026.0312
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e404944e9 | ||
|
|
53daf2c9a7 | ||
|
|
c36d63a0ad | ||
|
|
69ec2dfdb5 | ||
|
|
d2e6b692f3 | ||
|
|
baa1aa6edd | ||
|
|
38cb3dc031 | ||
|
|
abb73a4c65 | ||
|
|
2d1e6c0ca4 | ||
|
|
b38a8c55d6 | ||
|
|
ca872f2049 | ||
|
|
1b6ff59af3 | ||
|
|
e31bfac2ac | ||
|
|
99bb446ee9 | ||
|
|
088f3d37ef | ||
|
|
7f200e5334 | ||
|
|
ae16417886 | ||
|
|
467371946d | ||
|
|
2d23328ad0 | ||
|
|
0dfb84bf4e | ||
|
|
193a193e8a | ||
|
|
c1d1ec7d0c | ||
|
|
b415b63b4b | ||
|
|
818b9dbfb2 | ||
|
|
8085b89c07 | ||
|
|
86771a3c55 | ||
|
|
4b668ac2e9 | ||
|
|
9deb563c05 | ||
|
|
15ac3e2798 | ||
|
|
97addf7ba6 | ||
|
|
025bd737e7 | ||
|
|
07b675dec6 | ||
|
|
d675ce18dd | ||
|
|
a8e31bb2e8 | ||
|
|
3cd96ba961 | ||
|
|
a98fc6314e | ||
|
|
ae1ea71f58 | ||
|
|
4dff79cd7b | ||
|
|
cbcb329c70 | ||
|
|
7abf96abf2 | ||
|
|
13bf74178c | ||
|
|
66a00c9569 | ||
|
|
b60fa008eb |
2
.github/workflows/build-artifacts.yml
vendored
2
.github/workflows/build-artifacts.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -80,6 +80,7 @@ create_feature_graphic.py
|
|||||||
# Tool caches and local state
|
# Tool caches and local state
|
||||||
.cachebro/
|
.cachebro/
|
||||||
.osgrep/
|
.osgrep/
|
||||||
|
third_party/lpcnet_flutter/
|
||||||
|
|
||||||
# Claude Code local settings (permissions, personal config)
|
# Claude Code local settings (permissions, personal config)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
||||||
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
||||||
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp
|
`iOS TestFlight:` https://testflight.apple.com/join/nCZYMPPz
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -8,6 +9,7 @@ import Flutter
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,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>
|
||||||
|
|||||||
@@ -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 'Fastlane::Actions.execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:255:in 'block in Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:157:in 'Fastlane::Runner#trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/fast_file.rb:159:in 'Fastlane::FastFile#method_missing' Fastfile:23:in 'block (2 levels) in Fastlane::FastFile#parsing_binding' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane.rb:41:in 'Fastlane::Lane#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:49:in 'block in Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane_manager.rb:46:in 'Fastlane::LaneManager.cruise_lane' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/command_line_handler.rb:34:in 'Fastlane::CommandLineHandler.handle' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:110:in 'block (2 levels) in Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in 'Commander::Command#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in 'Commander::Command#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in 'Commander::Runner#run_active_command' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in 'Commander::Runner#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in 'Commander::Delegates#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:363:in 'Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:43:in 'Fastlane::CommandsGenerator.start' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/cli_tools_distributor.rb:132:in 'Fastlane::CLIToolsDistributor.take_off' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/bin/fastlane:23:in '<top (required)>' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Kernel#load' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Gem.activate_and_load_bin_path' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/bin/fastlane:25:in '<main>' Error uploading ipa file: [Application Loader Error Output]: [ContentDelivery.Uploader.104B12FA0]
|
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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ı açı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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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ı açı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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
31
lib/models/contact_group.dart
Normal file
31
lib/models/contact_group.dart
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
class SavedContactGroup {
|
||||||
|
final String id;
|
||||||
|
final String sectionKey;
|
||||||
|
final String label;
|
||||||
|
final String query;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
const SavedContactGroup({
|
||||||
|
required this.id,
|
||||||
|
required this.sectionKey,
|
||||||
|
required this.label,
|
||||||
|
required this.query,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
SavedContactGroup copyWith({
|
||||||
|
String? id,
|
||||||
|
String? sectionKey,
|
||||||
|
String? label,
|
||||||
|
String? query,
|
||||||
|
DateTime? createdAt,
|
||||||
|
}) {
|
||||||
|
return SavedContactGroup(
|
||||||
|
id: id ?? this.id,
|
||||||
|
sectionKey: sectionKey ?? this.sectionKey,
|
||||||
|
label: label ?? this.label,
|
||||||
|
query: query ?? this.query,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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++;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
@@ -299,11 +300,15 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final pcm = await _codec.decodePackets(session.packets, session.mode);
|
final decodedPcm = await _codec.decodePackets(
|
||||||
|
session.packets,
|
||||||
|
session.mode,
|
||||||
|
);
|
||||||
|
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
|
||||||
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
||||||
_playingSessionId = sessionId;
|
_playingSessionId = sessionId;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _player.play(pcm);
|
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
||||||
if (_playingSessionId == sessionId) {
|
if (_playingSessionId == sessionId) {
|
||||||
@@ -319,6 +324,10 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
|
||||||
|
return pcm;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clearStoredVoiceData() async {
|
Future<void> clearStoredVoiceData() async {
|
||||||
_sessions.clear();
|
_sessions.clear();
|
||||||
_outgoingSessions.clear();
|
_outgoingSessions.clear();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
1013
lib/screens/live_traffic_screen.dart
Normal file
1013
lib/screens/live_traffic_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
775
lib/screens/repeaters_map_screen.dart
Normal file
775
lib/screens/repeaters_map_screen.dart
Normal file
@@ -0,0 +1,775 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/contacts_provider.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
|
class RepeatersMapScreen extends StatefulWidget {
|
||||||
|
const RepeatersMapScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RepeatersMapScreen> createState() => _RepeatersMapScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
|
||||||
|
static const LatLng _fallbackCenter = LatLng(46.0569, 14.5058);
|
||||||
|
static const double _fallbackZoom = 7;
|
||||||
|
static const double _myLocationZoom = 14;
|
||||||
|
|
||||||
|
final flutter_map.MapController _mapController = flutter_map.MapController();
|
||||||
|
|
||||||
|
List<MeshMapNode> _onlineRepeaters = const [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _didFitCamera = false;
|
||||||
|
String? _error;
|
||||||
|
double _currentZoom = _fallbackZoom;
|
||||||
|
LatLng? _myLocation;
|
||||||
|
final Set<String> _addingRepeaters = <String>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadRepeaters();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRepeaters({bool forceRefresh = false}) async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
if (forceRefresh) {
|
||||||
|
_didFitCamera = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nodes = await MeshMapNodesService.fetchNodes(
|
||||||
|
forceRefresh: forceRefresh,
|
||||||
|
);
|
||||||
|
final repeaters = nodes.where(MeshMapNodesService.isRepeater).toList();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_onlineRepeaters = repeaters;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
_refreshMyLocation();
|
||||||
|
} catch (error) {
|
||||||
|
final cached = await MeshMapNodesService.loadCachedNodes();
|
||||||
|
final repeaters = cached.where(MeshMapNodesService.isRepeater).toList();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_onlineRepeaters = repeaters;
|
||||||
|
_isLoading = false;
|
||||||
|
_error = repeaters.isEmpty
|
||||||
|
? 'Unable to load repeaters right now.'
|
||||||
|
: 'Showing cached repeaters. Pull to retry.';
|
||||||
|
});
|
||||||
|
_refreshMyLocation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_MapRepeater> _mergeRepeaters({
|
||||||
|
required List<MeshMapNode> onlineNodes,
|
||||||
|
required List<Contact> contactRepeaters,
|
||||||
|
}) {
|
||||||
|
final merged = <String, _MapRepeater>{};
|
||||||
|
|
||||||
|
for (final node in onlineNodes) {
|
||||||
|
merged[node.publicKey] = _MapRepeater(
|
||||||
|
publicKey: node.publicKey,
|
||||||
|
name: node.name,
|
||||||
|
latitude: node.latitude,
|
||||||
|
longitude: node.longitude,
|
||||||
|
isFromContacts: false,
|
||||||
|
isFromOnline: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final contact in contactRepeaters) {
|
||||||
|
final location = contact.displayLocation;
|
||||||
|
if (location == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final key = contact.publicKeyHex.toLowerCase();
|
||||||
|
final existing = merged[key];
|
||||||
|
merged[key] = _MapRepeater(
|
||||||
|
publicKey: key,
|
||||||
|
name: contact.displayName.isNotEmpty
|
||||||
|
? contact.displayName
|
||||||
|
: existing?.name ?? 'Unknown repeater',
|
||||||
|
latitude: existing?.latitude ?? location.latitude,
|
||||||
|
longitude: existing?.longitude ?? location.longitude,
|
||||||
|
isFromContacts: true,
|
||||||
|
isFromOnline: existing?.isFromOnline ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final repeaters = merged.values.toList()
|
||||||
|
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
return repeaters;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshMyLocation() async {
|
||||||
|
try {
|
||||||
|
final position = await Geolocator.getCurrentPosition(
|
||||||
|
locationSettings: const LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
distanceFilter: 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_myLocation = LatLng(position.latitude, position.longitude);
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Best effort only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fitCameraIfNeeded(List<_MapRepeater> repeaters) {
|
||||||
|
if (_didFitCamera || repeaters.isEmpty || !mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || _didFitCamera || repeaters.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (repeaters.length == 1) {
|
||||||
|
final repeater = repeaters.first;
|
||||||
|
_mapController.move(LatLng(repeater.latitude, repeater.longitude), 13);
|
||||||
|
_currentZoom = 13;
|
||||||
|
} else {
|
||||||
|
final bounds = flutter_map.LatLngBounds.fromPoints(
|
||||||
|
repeaters
|
||||||
|
.map((node) => LatLng(node.latitude, node.longitude))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
_mapController.fitCamera(
|
||||||
|
flutter_map.CameraFit.bounds(
|
||||||
|
bounds: bounds,
|
||||||
|
padding: const EdgeInsets.all(48),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_didFitCamera = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showRepeaterDetails(_MapRepeater repeater) {
|
||||||
|
final isAdding = _addingRepeaters.contains(repeater.publicKey);
|
||||||
|
final canAdd = !repeater.isFromContacts;
|
||||||
|
final isConnected = context
|
||||||
|
.read<ConnectionProvider>()
|
||||||
|
.deviceInfo
|
||||||
|
.isConnected;
|
||||||
|
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
repeater.name,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_InfoLine(
|
||||||
|
label: 'Public key',
|
||||||
|
value: repeater.publicKey,
|
||||||
|
monospace: true,
|
||||||
|
),
|
||||||
|
_InfoLine(
|
||||||
|
label: 'Coordinates',
|
||||||
|
value:
|
||||||
|
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
|
||||||
|
),
|
||||||
|
_InfoLine(label: 'Source', value: repeater.sourceLabel),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (canAdd)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: !isConnected || isAdding
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
await _addRepeaterToContacts(repeater);
|
||||||
|
},
|
||||||
|
icon: isAdding
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.person_add_alt_1),
|
||||||
|
label: Text(
|
||||||
|
!isConnected
|
||||||
|
? 'Connect device to add'
|
||||||
|
: 'Add to contacts',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: null,
|
||||||
|
icon: const Icon(Icons.check_circle),
|
||||||
|
label: const Text('Already in contacts'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addRepeaterToContacts(_MapRepeater repeater) async {
|
||||||
|
if (_addingRepeaters.contains(repeater.publicKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Connect to a device before adding contacts'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_addingRepeaters.add(repeater.publicKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await connectionProvider.getContact(_hexToBytes(repeater.publicKey));
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('${repeater.name} added to contacts')),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to add ${repeater.name}: $error')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_addingRepeaters.remove(repeater.publicKey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _hexToBytes(String hex) {
|
||||||
|
final normalized = hex.replaceAll(':', '').trim().toLowerCase();
|
||||||
|
return Uint8List.fromList(
|
||||||
|
List<int>.generate(
|
||||||
|
normalized.length ~/ 2,
|
||||||
|
(index) => int.parse(
|
||||||
|
normalized.substring(index * 2, index * 2 + 2),
|
||||||
|
radix: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contactsProvider = context.watch<ContactsProvider>();
|
||||||
|
final repeaters = _mergeRepeaters(
|
||||||
|
onlineNodes: _onlineRepeaters,
|
||||||
|
contactRepeaters: contactsProvider.repeaters,
|
||||||
|
);
|
||||||
|
_fitCameraIfNeeded(repeaters);
|
||||||
|
final clusters = _buildClusters(repeaters);
|
||||||
|
final markers = clusters.map((cluster) {
|
||||||
|
final markerColor = switch (cluster.status) {
|
||||||
|
_ClusterStatus.contactsOnly => const Color(0xFF7C3AED),
|
||||||
|
_ClusterStatus.onlineOnly => const Color(0xFFE8681D),
|
||||||
|
_ClusterStatus.both => const Color(0xFF1B8F4F),
|
||||||
|
_ClusterStatus.mixed => const Color(0xFF2563EB),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cluster.members.length == 1) {
|
||||||
|
final repeater = cluster.members.first;
|
||||||
|
return flutter_map.Marker(
|
||||||
|
point: cluster.center,
|
||||||
|
width: 80,
|
||||||
|
height: 100,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _showRepeaterDetails(repeater),
|
||||||
|
child: _RepeaterMarker(color: markerColor, label: repeater.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return flutter_map.Marker(
|
||||||
|
point: cluster.center,
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => _zoomToCluster(cluster),
|
||||||
|
child: _ClusterMarker(
|
||||||
|
color: markerColor,
|
||||||
|
count: cluster.members.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
final allMarkers = <flutter_map.Marker>[
|
||||||
|
...markers,
|
||||||
|
if (_myLocation != null)
|
||||||
|
flutter_map.Marker(
|
||||||
|
point: _myLocation!,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 3),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.18),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Repeaters Map'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: _isLoading
|
||||||
|
? null
|
||||||
|
: () => _loadRepeaters(forceRefresh: true),
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
flutter_map.FlutterMap(
|
||||||
|
mapController: _mapController,
|
||||||
|
options: flutter_map.MapOptions(
|
||||||
|
initialCenter: _fallbackCenter,
|
||||||
|
initialZoom: _fallbackZoom,
|
||||||
|
onPositionChanged: (camera, hasGesture) {
|
||||||
|
final nextZoom = camera.zoom;
|
||||||
|
if (nextZoom != _currentZoom && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_currentZoom = nextZoom;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
flutter_map.TileLayer(
|
||||||
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'app.meshcore.sar',
|
||||||
|
),
|
||||||
|
flutter_map.MarkerLayer(markers: allMarkers),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_error != null)
|
||||||
|
Positioned(
|
||||||
|
top: 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Material(
|
||||||
|
elevation: 2,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Text(_error!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||||
|
if (!_isLoading && repeaters.isEmpty)
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
_error ?? 'No repeaters available.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (repeaters.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
top: _error == null ? 16 : 72,
|
||||||
|
child: Material(
|
||||||
|
elevation: 2,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: const [
|
||||||
|
_LegendRow(
|
||||||
|
color: Color(0xFF7C3AED),
|
||||||
|
label: 'From contacts',
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
_LegendRow(
|
||||||
|
color: Color(0xFFE8681D),
|
||||||
|
label: 'Online only',
|
||||||
|
),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
_LegendRow(color: Color(0xFF1B8F4F), label: 'In both'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (repeaters.isNotEmpty)
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: Material(
|
||||||
|
elevation: 3,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${repeaters.length} repeaters',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _jumpToMyLocation,
|
||||||
|
tooltip: 'My location',
|
||||||
|
child: const Icon(Icons.my_location),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _jumpToMyLocation() async {
|
||||||
|
await _refreshMyLocation();
|
||||||
|
final myLocation = _myLocation;
|
||||||
|
if (!mounted || myLocation == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_mapController.move(myLocation, _myLocationZoom);
|
||||||
|
setState(() {
|
||||||
|
_currentZoom = _myLocationZoom;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_RepeaterCluster> _buildClusters(List<_MapRepeater> repeaters) {
|
||||||
|
if (repeaters.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentZoom >= 11.5) {
|
||||||
|
return repeaters
|
||||||
|
.map(
|
||||||
|
(repeater) => _RepeaterCluster(
|
||||||
|
members: [repeater],
|
||||||
|
center: LatLng(repeater.latitude, repeater.longitude),
|
||||||
|
status: repeater.clusterStatus,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
final cellSize = _gridSizeForZoom(_currentZoom);
|
||||||
|
final buckets = <String, List<_MapRepeater>>{};
|
||||||
|
|
||||||
|
for (final repeater in repeaters) {
|
||||||
|
final latBucket = (repeater.latitude / cellSize).floor();
|
||||||
|
final lonBucket = (repeater.longitude / cellSize).floor();
|
||||||
|
final key = '$latBucket:$lonBucket';
|
||||||
|
buckets.putIfAbsent(key, () => <_MapRepeater>[]).add(repeater);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buckets.values.map((members) {
|
||||||
|
final centerLat =
|
||||||
|
members.map((node) => node.latitude).reduce((a, b) => a + b) /
|
||||||
|
members.length;
|
||||||
|
final centerLon =
|
||||||
|
members.map((node) => node.longitude).reduce((a, b) => a + b) /
|
||||||
|
members.length;
|
||||||
|
final statuses = members.map((node) => node.clusterStatus).toSet();
|
||||||
|
return _RepeaterCluster(
|
||||||
|
members: members,
|
||||||
|
center: LatLng(centerLat, centerLon),
|
||||||
|
status: statuses.length == 1 ? statuses.first : _ClusterStatus.mixed,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
double _gridSizeForZoom(double zoom) {
|
||||||
|
if (zoom < 5) return 3.0;
|
||||||
|
if (zoom < 7) return 1.5;
|
||||||
|
if (zoom < 9) return 0.7;
|
||||||
|
if (zoom < 10.5) return 0.28;
|
||||||
|
return 0.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _zoomToCluster(_RepeaterCluster cluster) {
|
||||||
|
if (cluster.members.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final bounds = flutter_map.LatLngBounds.fromPoints(
|
||||||
|
cluster.members
|
||||||
|
.map((node) => LatLng(node.latitude, node.longitude))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
_mapController.fitCamera(
|
||||||
|
flutter_map.CameraFit.bounds(
|
||||||
|
bounds: bounds,
|
||||||
|
padding: const EdgeInsets.all(64),
|
||||||
|
maxZoom: (_currentZoom + 2).clamp(8, 14),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoLine extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final bool monospace;
|
||||||
|
|
||||||
|
const _InfoLine({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.monospace = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 92,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: monospace
|
||||||
|
? const TextStyle(fontFamily: 'monospace')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LegendRow extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _LegendRow({required this.color, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(label),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RepeaterMarker extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _RepeaterMarker({required this.color, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
child: const Icon(Icons.router, color: Colors.white, size: 18),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 80),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ClusterMarker extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
const _ClusterMarker({required this.color, required this.count});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.22),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
'$count',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _ClusterStatus { contactsOnly, onlineOnly, both, mixed }
|
||||||
|
|
||||||
|
class _RepeaterCluster {
|
||||||
|
final List<_MapRepeater> members;
|
||||||
|
final LatLng center;
|
||||||
|
final _ClusterStatus status;
|
||||||
|
|
||||||
|
const _RepeaterCluster({
|
||||||
|
required this.members,
|
||||||
|
required this.center,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MapRepeater {
|
||||||
|
final String publicKey;
|
||||||
|
final String name;
|
||||||
|
final double latitude;
|
||||||
|
final double longitude;
|
||||||
|
final bool isFromContacts;
|
||||||
|
final bool isFromOnline;
|
||||||
|
|
||||||
|
const _MapRepeater({
|
||||||
|
required this.publicKey,
|
||||||
|
required this.name,
|
||||||
|
required this.latitude,
|
||||||
|
required this.longitude,
|
||||||
|
required this.isFromContacts,
|
||||||
|
required this.isFromOnline,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get sourceLabel {
|
||||||
|
if (isFromContacts && isFromOnline) return 'Contacts and online';
|
||||||
|
if (isFromContacts) return 'Contacts';
|
||||||
|
return 'Online';
|
||||||
|
}
|
||||||
|
|
||||||
|
_ClusterStatus get clusterStatus {
|
||||||
|
if (isFromContacts && isFromOnline) return _ClusterStatus.both;
|
||||||
|
if (isFromContacts) return _ClusterStatus.contactsOnly;
|
||||||
|
return _ClusterStatus.onlineOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -58,6 +59,16 @@ class BackgroundLocationService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Platform.isIOS && permission != LocationPermission.always) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
if (permission != LocationPermission.always) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save settings
|
// Save settings
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_prefKeyEnabled, true);
|
await prefs.setBool(_prefKeyEnabled, true);
|
||||||
@@ -68,8 +79,7 @@ class BackgroundLocationService {
|
|||||||
try {
|
try {
|
||||||
_positionSubscription =
|
_positionSubscription =
|
||||||
Geolocator.getPositionStream(
|
Geolocator.getPositionStream(
|
||||||
locationSettings: LocationSettings(
|
locationSettings: _buildLocationSettings(
|
||||||
accuracy: LocationAccuracy.best,
|
|
||||||
distanceFilter: distanceThreshold.toInt(),
|
distanceFilter: distanceThreshold.toInt(),
|
||||||
),
|
),
|
||||||
).listen((Position position) async {
|
).listen((Position position) async {
|
||||||
@@ -171,4 +181,22 @@ class BackgroundLocationService {
|
|||||||
await startTracking(distanceThreshold: distance);
|
await startTracking(distanceThreshold: distance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LocationSettings _buildLocationSettings({int distanceFilter = 10}) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return AppleSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
activityType: ActivityType.fitness,
|
||||||
|
allowBackgroundLocationUpdates: true,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
pauseLocationUpdatesAutomatically: false,
|
||||||
|
showBackgroundLocationIndicator: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
/// Service for accessing build information from native platform code
|
/// Service for accessing build information from native platform code
|
||||||
/// Currently supports Android only - returns "unknown" for other platforms
|
/// Currently supports Android only - returns "unknown" for other platforms
|
||||||
@@ -25,7 +24,7 @@ class BuildInfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only Android has the platform channel implementation
|
// Only Android has the platform channel implementation
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
||||||
_cachedCommitHash = 'unknown';
|
_cachedCommitHash = 'unknown';
|
||||||
return _cachedCommitHash!;
|
return _cachedCommitHash!;
|
||||||
|
|||||||
336
lib/services/contact_route_resolver.dart
Normal file
336
lib/services/contact_route_resolver.dart
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class ResolvedContactRoutePlan {
|
||||||
|
final List<String> tokens;
|
||||||
|
final List<Contact> selectedContacts;
|
||||||
|
final String summary;
|
||||||
|
|
||||||
|
const ResolvedContactRoutePlan({
|
||||||
|
required this.tokens,
|
||||||
|
required this.selectedContacts,
|
||||||
|
required this.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get canonicalText => tokens.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactRouteResolver {
|
||||||
|
static const Distance _distance = Distance();
|
||||||
|
|
||||||
|
const ContactRouteResolver._();
|
||||||
|
|
||||||
|
static ResolvedContactRoutePlan? resolveAutomaticRoute({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required Contact recipient,
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final recipientLocation = recipient.displayLocation;
|
||||||
|
if (recipientLocation == null) return null;
|
||||||
|
|
||||||
|
final repeaters = availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.isRepeater &&
|
||||||
|
contact.displayLocation != null &&
|
||||||
|
contact.publicKeyHex != recipient.publicKeyHex,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
if (repeaters.isEmpty) return null;
|
||||||
|
|
||||||
|
final recipientLatLng = LatLng(
|
||||||
|
recipientLocation.latitude,
|
||||||
|
recipientLocation.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final routedCandidates =
|
||||||
|
repeaters
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.routeHasPath && contact.routeHashSize == hashSize,
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(a, b) =>
|
||||||
|
_scoreKnownRouteRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: a,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
hashSize: hashSize,
|
||||||
|
).compareTo(
|
||||||
|
_scoreKnownRouteRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: b,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
hashSize: hashSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (routedCandidates.isNotEmpty) {
|
||||||
|
final anchor = routedCandidates.first;
|
||||||
|
final tokens = <String>[
|
||||||
|
...anchor.routeCanonicalText
|
||||||
|
.split(',')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.map((token) => token.toUpperCase()),
|
||||||
|
_tokenFor(anchor, hashSize),
|
||||||
|
];
|
||||||
|
final selectedContacts = _matchContactsForTokens(
|
||||||
|
tokens,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
);
|
||||||
|
return ResolvedContactRoutePlan(
|
||||||
|
tokens: _dedupeTokens(tokens),
|
||||||
|
selectedContacts: selectedContacts,
|
||||||
|
summary: 'Resolved via known repeater route',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final corridorRepeaters = List<Contact>.from(repeaters)
|
||||||
|
..sort((a, b) {
|
||||||
|
final progressA = _progressAlongSegment(
|
||||||
|
point: LatLng(
|
||||||
|
a.displayLocation!.latitude,
|
||||||
|
a.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
start: senderLocation,
|
||||||
|
end: recipientLatLng,
|
||||||
|
);
|
||||||
|
final progressB = _progressAlongSegment(
|
||||||
|
point: LatLng(
|
||||||
|
b.displayLocation!.latitude,
|
||||||
|
b.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
start: senderLocation,
|
||||||
|
end: recipientLatLng,
|
||||||
|
);
|
||||||
|
final progressCompare = progressA.compareTo(progressB);
|
||||||
|
if (progressCompare != 0) return progressCompare;
|
||||||
|
return _scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: a,
|
||||||
|
).compareTo(
|
||||||
|
_scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: b,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final selected = <Contact>[];
|
||||||
|
var currentPoint = senderLocation;
|
||||||
|
var currentDistanceToRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
senderLocation,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
final maxCorridorDistance = math.max(
|
||||||
|
1500.0,
|
||||||
|
currentDistanceToRecipient * 0.22,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final repeater in corridorRepeaters) {
|
||||||
|
if (selected.length >= 4) break;
|
||||||
|
final repeaterPoint = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
final distanceToSegment = _distanceToSegmentMeters(
|
||||||
|
repeaterPoint,
|
||||||
|
senderLocation,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
if (distanceToSegment > maxCorridorDistance) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextDistanceToRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
repeaterPoint,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final distanceFromCurrent = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
currentPoint,
|
||||||
|
repeaterPoint,
|
||||||
|
);
|
||||||
|
if (distanceFromCurrent < 100) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.add(repeater);
|
||||||
|
currentPoint = repeaterPoint;
|
||||||
|
currentDistanceToRecipient = nextDistanceToRecipient;
|
||||||
|
|
||||||
|
if (currentDistanceToRecipient < 2500) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolvedContactRoutePlan(
|
||||||
|
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
|
||||||
|
selectedContacts: selected,
|
||||||
|
summary: 'Resolved from repeater locations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Contact> _matchContactsForTokens(
|
||||||
|
List<String> tokens, {
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
}) {
|
||||||
|
final matches = <Contact>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
for (final token in tokens) {
|
||||||
|
final match = availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||||
|
)
|
||||||
|
.firstOrNull;
|
||||||
|
if (match != null && seen.add(match.publicKeyHex)) {
|
||||||
|
matches.add(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _dedupeTokens(List<String> tokens) {
|
||||||
|
final result = <String>[];
|
||||||
|
for (final token in tokens) {
|
||||||
|
if (result.isEmpty || result.last != token) {
|
||||||
|
result.add(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _tokenFor(Contact contact, int hashSize) {
|
||||||
|
final hex = contact.publicKeyHex.toUpperCase();
|
||||||
|
final length = hashSize * 2;
|
||||||
|
return hex.length < length ? hex : hex.substring(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreRepeater({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required LatLng recipientLocation,
|
||||||
|
required Contact repeater,
|
||||||
|
}) {
|
||||||
|
final point = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
final toRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
point,
|
||||||
|
recipientLocation,
|
||||||
|
);
|
||||||
|
final corridor = _distanceToSegmentMeters(
|
||||||
|
point,
|
||||||
|
senderLocation,
|
||||||
|
recipientLocation,
|
||||||
|
);
|
||||||
|
return (toRecipient * 0.75) + (corridor * 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreKnownRouteRepeater({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required LatLng recipientLocation,
|
||||||
|
required Contact repeater,
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final baseScore = _scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLocation,
|
||||||
|
repeater: repeater,
|
||||||
|
);
|
||||||
|
final repeaterPoint = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final chainContacts = repeater.routeCanonicalText
|
||||||
|
.split(',')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.map(
|
||||||
|
(token) => availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.displayLocation != null &&
|
||||||
|
contact.publicKeyHex.toUpperCase().startsWith(
|
||||||
|
token.toUpperCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.firstOrNull,
|
||||||
|
)
|
||||||
|
.whereType<Contact>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final chainEnd = chainContacts.isNotEmpty
|
||||||
|
? LatLng(
|
||||||
|
chainContacts.last.displayLocation!.latitude,
|
||||||
|
chainContacts.last.displayLocation!.longitude,
|
||||||
|
)
|
||||||
|
: senderLocation;
|
||||||
|
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
|
||||||
|
final senderGap = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
senderLocation,
|
||||||
|
repeaterPoint,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _progressAlongSegment({
|
||||||
|
required LatLng point,
|
||||||
|
required LatLng start,
|
||||||
|
required LatLng end,
|
||||||
|
}) {
|
||||||
|
final dx = end.longitude - start.longitude;
|
||||||
|
final dy = end.latitude - start.latitude;
|
||||||
|
final lengthSquared = (dx * dx) + (dy * dy);
|
||||||
|
if (lengthSquared == 0) return 0;
|
||||||
|
return (((point.longitude - start.longitude) * dx) +
|
||||||
|
((point.latitude - start.latitude) * dy)) /
|
||||||
|
lengthSquared;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||||
|
final ax = a.longitude;
|
||||||
|
final ay = a.latitude;
|
||||||
|
final bx = b.longitude;
|
||||||
|
final by = b.latitude;
|
||||||
|
final px = p.longitude;
|
||||||
|
final py = p.latitude;
|
||||||
|
|
||||||
|
final abx = bx - ax;
|
||||||
|
final aby = by - ay;
|
||||||
|
final apx = px - ax;
|
||||||
|
final apy = py - ay;
|
||||||
|
final ab2 = abx * abx + aby * aby;
|
||||||
|
if (ab2 == 0) {
|
||||||
|
return _distance.as(LengthUnit.Meter, a, p);
|
||||||
|
}
|
||||||
|
var t = (apx * abx + apy * aby) / ab2;
|
||||||
|
t = t.clamp(0.0, 1.0);
|
||||||
|
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||||
|
return _distance.as(LengthUnit.Meter, closest, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../models/contact_group.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting contacts to local storage
|
/// Service for persisting contacts to local storage
|
||||||
class ContactStorageService {
|
class ContactStorageService {
|
||||||
static const String _contactsKey = 'stored_contacts';
|
static const String _contactsKey = 'stored_contacts';
|
||||||
|
static const String _contactGroupsKey = 'stored_contact_groups';
|
||||||
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
||||||
|
|
||||||
/// Save contacts to persistent storage
|
/// Save contacts to persistent storage
|
||||||
@@ -90,6 +92,40 @@ class ContactStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = jsonEncode(
|
||||||
|
groups.map((group) => _contactGroupToJson(group)).toList(),
|
||||||
|
);
|
||||||
|
await prefs.setString(_contactGroupsKey, jsonString);
|
||||||
|
debugPrint(
|
||||||
|
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SavedContactGroup>> loadContactGroups() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(_contactGroupsKey);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
|
return jsonList
|
||||||
|
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
|
||||||
|
.whereType<SavedContactGroup>()
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
try {
|
try {
|
||||||
@@ -203,4 +239,31 @@ class ContactStorageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
||||||
|
return {
|
||||||
|
'id': group.id,
|
||||||
|
'sectionKey': group.sectionKey,
|
||||||
|
'label': group.label,
|
||||||
|
'query': group.query,
|
||||||
|
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
|
||||||
|
try {
|
||||||
|
return SavedContactGroup(
|
||||||
|
id: json['id'] as String,
|
||||||
|
sectionKey: json['sectionKey'] as String,
|
||||||
|
label: json['label'] as String,
|
||||||
|
query: json['query'] as String,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
json['createdAtMillis'] as int,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
246
lib/services/live_traffic_summary.dart
Normal file
246
lib/services/live_traffic_summary.dart
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import '../models/ble_packet_log.dart';
|
||||||
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
|
enum LiveTrafficBusyness { quiet, active, busy }
|
||||||
|
|
||||||
|
class LiveTrafficEntry {
|
||||||
|
final BlePacketLog log;
|
||||||
|
final DecodedLogRxRoute? route;
|
||||||
|
|
||||||
|
const LiveTrafficEntry({required this.log, required this.route});
|
||||||
|
|
||||||
|
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
|
||||||
|
|
||||||
|
int? get hopCount => route?.hopCount;
|
||||||
|
|
||||||
|
String get payloadLabel {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null) {
|
||||||
|
return log.responseCode != null ? log.opcodeName : 'Unknown';
|
||||||
|
}
|
||||||
|
return payloadTypeLabel(decodedRoute.payloadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get payloadMeaning {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null) return null;
|
||||||
|
return payloadTypeMeaning(decodedRoute.payloadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get routePreview {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
|
||||||
|
return 'Direct packet';
|
||||||
|
}
|
||||||
|
return decodedRoute.hopHashes
|
||||||
|
.map((hashHex) => '0x${hashHex.toUpperCase()}')
|
||||||
|
.join(' -> ');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String payloadTypeLabel(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'Request';
|
||||||
|
case 0x01:
|
||||||
|
return 'Response';
|
||||||
|
case 0x02:
|
||||||
|
return 'Text message';
|
||||||
|
case 0x03:
|
||||||
|
return 'Ack';
|
||||||
|
case 0x04:
|
||||||
|
return 'Advertisement';
|
||||||
|
case 0x05:
|
||||||
|
return 'Group text';
|
||||||
|
case 0x06:
|
||||||
|
return 'Group datagram';
|
||||||
|
case 0x07:
|
||||||
|
return 'Anonymous request';
|
||||||
|
case 0x08:
|
||||||
|
return 'Returned path';
|
||||||
|
case 0x09:
|
||||||
|
return 'Trace path';
|
||||||
|
case 0x0A:
|
||||||
|
return 'Multipart packet';
|
||||||
|
case 0x0B:
|
||||||
|
return 'Control packet';
|
||||||
|
default:
|
||||||
|
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String payloadTypeMeaning(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'Request (destination/source hashes + MAC)';
|
||||||
|
case 0x01:
|
||||||
|
return 'Response to Request or Anonymous request';
|
||||||
|
case 0x02:
|
||||||
|
return 'Plain text message';
|
||||||
|
case 0x03:
|
||||||
|
return 'Simple acknowledgement';
|
||||||
|
case 0x04:
|
||||||
|
return 'Node advertisement';
|
||||||
|
case 0x05:
|
||||||
|
return 'Unverified group text message';
|
||||||
|
case 0x06:
|
||||||
|
return 'Unverified group datagram';
|
||||||
|
case 0x07:
|
||||||
|
return 'Generic anonymous request';
|
||||||
|
case 0x08:
|
||||||
|
return 'Returned path payload';
|
||||||
|
case 0x09:
|
||||||
|
return 'Trace path collecting hop SNR';
|
||||||
|
case 0x0A:
|
||||||
|
return 'One packet from a multipart set';
|
||||||
|
case 0x0B:
|
||||||
|
return 'Control or discovery packet';
|
||||||
|
default:
|
||||||
|
return 'protocol payload';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveTrafficSnapshot {
|
||||||
|
final DateTime windowStart;
|
||||||
|
final Duration windowDuration;
|
||||||
|
final int packetsPerMinute;
|
||||||
|
final int rxCount;
|
||||||
|
final int txCount;
|
||||||
|
final int totalCount;
|
||||||
|
final double? avgSnrDb;
|
||||||
|
final double? latestSnrDb;
|
||||||
|
final double? avgRssiDbm;
|
||||||
|
final int? latestRssiDbm;
|
||||||
|
final int multiHopCount;
|
||||||
|
final double? avgHopCount;
|
||||||
|
final List<LiveTrafficEntry> visibleEntries;
|
||||||
|
final LiveTrafficBusyness busyness;
|
||||||
|
|
||||||
|
const LiveTrafficSnapshot({
|
||||||
|
required this.windowStart,
|
||||||
|
required this.windowDuration,
|
||||||
|
required this.packetsPerMinute,
|
||||||
|
required this.rxCount,
|
||||||
|
required this.txCount,
|
||||||
|
required this.totalCount,
|
||||||
|
required this.avgSnrDb,
|
||||||
|
required this.latestSnrDb,
|
||||||
|
required this.avgRssiDbm,
|
||||||
|
required this.latestRssiDbm,
|
||||||
|
required this.multiHopCount,
|
||||||
|
required this.avgHopCount,
|
||||||
|
required this.visibleEntries,
|
||||||
|
required this.busyness,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveTrafficSummary {
|
||||||
|
static const Duration rollingWindow = Duration(seconds: 60);
|
||||||
|
static const int maxVisibleEntries = 120;
|
||||||
|
static const int logRxDataResponseCode = 0x88;
|
||||||
|
|
||||||
|
const LiveTrafficSummary._();
|
||||||
|
|
||||||
|
static LiveTrafficSnapshot fromLogs(
|
||||||
|
Iterable<BlePacketLog> logs, {
|
||||||
|
required DateTime now,
|
||||||
|
DateTime? clearedAt,
|
||||||
|
int? preferredHashSize,
|
||||||
|
Duration window = rollingWindow,
|
||||||
|
String? packetTypeFilter,
|
||||||
|
}) {
|
||||||
|
final windowStart = now.subtract(window);
|
||||||
|
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
|
||||||
|
? clearedAt
|
||||||
|
: windowStart;
|
||||||
|
|
||||||
|
final recentLogs = logs
|
||||||
|
.where(
|
||||||
|
(log) =>
|
||||||
|
log.direction == PacketDirection.rx &&
|
||||||
|
log.responseCode == logRxDataResponseCode &&
|
||||||
|
!log.timestamp.isBefore(effectiveStart),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||||
|
|
||||||
|
final entries = <LiveTrafficEntry>[];
|
||||||
|
for (final log in recentLogs) {
|
||||||
|
final route = LogRxRouteDecoder.decode(
|
||||||
|
log.rawData,
|
||||||
|
preferredHashSize: preferredHashSize,
|
||||||
|
);
|
||||||
|
entries.add(LiveTrafficEntry(log: log, route: route));
|
||||||
|
}
|
||||||
|
|
||||||
|
final filteredEntries = packetTypeFilter == null
|
||||||
|
? entries
|
||||||
|
: entries
|
||||||
|
.where((entry) => entry.payloadLabel == packetTypeFilter)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var rxCount = 0;
|
||||||
|
var snrCount = 0;
|
||||||
|
var snrSum = 0.0;
|
||||||
|
var rssiCount = 0;
|
||||||
|
var rssiSum = 0.0;
|
||||||
|
double? latestSnrDb;
|
||||||
|
int? latestRssiDbm;
|
||||||
|
var multiHopCount = 0;
|
||||||
|
var hopCountTotal = 0;
|
||||||
|
var hopCountSamples = 0;
|
||||||
|
|
||||||
|
for (final entry in filteredEntries) {
|
||||||
|
rxCount += 1;
|
||||||
|
|
||||||
|
final rxInfo = entry.log.logRxDataInfo;
|
||||||
|
if (rxInfo?.snrDb != null) {
|
||||||
|
snrCount += 1;
|
||||||
|
snrSum += rxInfo!.snrDb!;
|
||||||
|
latestSnrDb = rxInfo.snrDb!;
|
||||||
|
}
|
||||||
|
if (rxInfo?.rssiDbm != null) {
|
||||||
|
rssiCount += 1;
|
||||||
|
rssiSum += rxInfo!.rssiDbm!.toDouble();
|
||||||
|
latestRssiDbm = rxInfo.rssiDbm!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final route = entry.route;
|
||||||
|
if (route != null && route.hopCount > 0) {
|
||||||
|
hopCountSamples += 1;
|
||||||
|
hopCountTotal += route.hopCount;
|
||||||
|
if (route.hopCount > 1) {
|
||||||
|
multiHopCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
|
||||||
|
const txCount = 0;
|
||||||
|
final totalCount = rxCount;
|
||||||
|
final packetsPerMinute = totalCount;
|
||||||
|
|
||||||
|
return LiveTrafficSnapshot(
|
||||||
|
windowStart: effectiveStart,
|
||||||
|
windowDuration: window,
|
||||||
|
packetsPerMinute: packetsPerMinute,
|
||||||
|
rxCount: rxCount,
|
||||||
|
txCount: txCount,
|
||||||
|
totalCount: totalCount,
|
||||||
|
avgSnrDb: snrCount == 0 ? null : snrSum / snrCount,
|
||||||
|
latestSnrDb: latestSnrDb,
|
||||||
|
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
|
||||||
|
latestRssiDbm: latestRssiDbm,
|
||||||
|
multiHopCount: multiHopCount,
|
||||||
|
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
|
||||||
|
visibleEntries: visibleEntries,
|
||||||
|
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) {
|
||||||
|
if (ppm <= 5) return LiveTrafficBusyness.quiet;
|
||||||
|
if (ppm <= 20) return LiveTrafficBusyness.active;
|
||||||
|
return LiveTrafficBusyness.busy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
21
lib/services/voice_codec_preferences.dart
Normal file
21
lib/services/voice_codec_preferences.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
|
class VoiceCodecPreferences {
|
||||||
|
static const String _codecKey = 'voice_codec';
|
||||||
|
static const VoiceCodecKind defaultCodec = VoiceCodecKind.codec2;
|
||||||
|
|
||||||
|
static Future<VoiceCodecKind> getCodec() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_codecKey);
|
||||||
|
return VoiceCodecKind.values.firstWhere(
|
||||||
|
(codec) => codec.name == raw,
|
||||||
|
orElse: () => defaultCodec,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> setCodec(VoiceCodecKind codec) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_codecKey, codec.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,18 +3,22 @@ import 'package:codec2_flutter/codec2_flutter.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
export 'package:codec2_flutter/codec2_flutter.dart' show Codec2Mode;
|
|
||||||
|
|
||||||
/// Maps [VoicePacketMode] to the [Codec2Mode] enum from the FFI plugin.
|
|
||||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||||
switch (pktMode) {
|
switch (pktMode) {
|
||||||
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
case VoicePacketMode.mode3200:
|
||||||
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
return Codec2Mode.mode3200;
|
||||||
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
case VoicePacketMode.mode1600:
|
||||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
return Codec2Mode.mode1600;
|
||||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
case VoicePacketMode.mode1400:
|
||||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
return Codec2Mode.mode1400;
|
||||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
case VoicePacketMode.mode700c:
|
||||||
|
return Codec2Mode.mode700c;
|
||||||
|
case VoicePacketMode.mode1200:
|
||||||
|
return Codec2Mode.mode1200;
|
||||||
|
case VoicePacketMode.mode1300:
|
||||||
|
return Codec2Mode.mode1300;
|
||||||
|
case VoicePacketMode.mode2400:
|
||||||
|
return Codec2Mode.mode2400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,14 +43,11 @@ class VoiceCodecService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode [pcm] (Int16 samples, 8000 Hz mono) with [mode].
|
|
||||||
/// Returns the raw Codec2-encoded bytes.
|
|
||||||
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
Future<Uint8List> encode(Int16List pcm, VoicePacketMode mode) {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
return Codec2.encodeInIsolate(pcm, codec2ModeFor(mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode [codec2Bytes] back to Int16 PCM (8000 Hz mono) with [mode].
|
|
||||||
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
Future<Int16List> decode(Uint8List codec2Bytes, VoicePacketMode mode) {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
return Codec2.decodeInIsolate(codec2Bytes, codec2ModeFor(mode));
|
||||||
@@ -59,24 +60,14 @@ class VoiceCodecService {
|
|||||||
VoicePacketMode mode,
|
VoicePacketMode mode,
|
||||||
) async {
|
) async {
|
||||||
_ensureCodec2Supported();
|
_ensureCodec2Supported();
|
||||||
final c2Mode = codec2ModeFor(mode);
|
|
||||||
final c2 = Codec2.create(c2Mode);
|
|
||||||
final spf = c2.samplesPerFrame;
|
|
||||||
c2.destroy();
|
|
||||||
|
|
||||||
// Estimate total samples (use actual data or silence per missing packet)
|
|
||||||
final all = <Int16List>[];
|
final all = <Int16List>[];
|
||||||
for (final pkt in packets) {
|
for (final pkt in packets) {
|
||||||
if (pkt == null || pkt.codec2Data.isEmpty) {
|
if (pkt == null || pkt.codec2Data.isEmpty) {
|
||||||
// Silence for missing packet — duration approximated by mode
|
all.add(Int16List(mode.samplesPerPacket));
|
||||||
final silenceSamples = (codec2ModeFor(mode).framesPerSecond) * spf;
|
|
||||||
all.add(Int16List(silenceSamples));
|
|
||||||
} else {
|
} else {
|
||||||
final decoded = await Codec2.decodeInIsolate(pkt.codec2Data, c2Mode);
|
all.add(await decode(pkt.codec2Data, mode));
|
||||||
all.add(decoded);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
final total = all.fold<int>(0, (sum, l) => sum + l.length);
|
||||||
final result = Int16List(total);
|
final result = Int16List(total);
|
||||||
var offset = 0;
|
var offset = 0;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
/// Web/unsupported platform stub — Codec2 FFI is not available.
|
|
||||||
enum Codec2Mode {
|
enum Codec2Mode {
|
||||||
mode3200(0),
|
mode3200(0),
|
||||||
mode2400(1),
|
mode2400(1),
|
||||||
@@ -19,13 +18,20 @@ enum Codec2Mode {
|
|||||||
|
|
||||||
int get bytesPerSecond {
|
int get bytesPerSecond {
|
||||||
switch (this) {
|
switch (this) {
|
||||||
case mode3200: return 400;
|
case mode3200:
|
||||||
case mode700c: return 100;
|
return 400;
|
||||||
case mode1200: return 150;
|
case mode700c:
|
||||||
case mode1300: return 175;
|
return 100;
|
||||||
case mode1400: return 175;
|
case mode1200:
|
||||||
case mode1600: return 200;
|
return 150;
|
||||||
case mode2400: return 300;
|
case mode1300:
|
||||||
|
return 175;
|
||||||
|
case mode1400:
|
||||||
|
return 175;
|
||||||
|
case mode1600:
|
||||||
|
return 200;
|
||||||
|
case mode2400:
|
||||||
|
return 300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,13 +46,20 @@ enum Codec2Mode {
|
|||||||
|
|
||||||
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
Codec2Mode codec2ModeFor(VoicePacketMode pktMode) {
|
||||||
switch (pktMode) {
|
switch (pktMode) {
|
||||||
case VoicePacketMode.mode3200: return Codec2Mode.mode3200;
|
case VoicePacketMode.mode3200:
|
||||||
case VoicePacketMode.mode1600: return Codec2Mode.mode1600;
|
return Codec2Mode.mode3200;
|
||||||
case VoicePacketMode.mode1400: return Codec2Mode.mode1400;
|
case VoicePacketMode.mode1600:
|
||||||
case VoicePacketMode.mode700c: return Codec2Mode.mode700c;
|
return Codec2Mode.mode1600;
|
||||||
case VoicePacketMode.mode1200: return Codec2Mode.mode1200;
|
case VoicePacketMode.mode1400:
|
||||||
case VoicePacketMode.mode1300: return Codec2Mode.mode1300;
|
return Codec2Mode.mode1400;
|
||||||
case VoicePacketMode.mode2400: return Codec2Mode.mode2400;
|
case VoicePacketMode.mode700c:
|
||||||
|
return Codec2Mode.mode700c;
|
||||||
|
case VoicePacketMode.mode1200:
|
||||||
|
return Codec2Mode.mode1200;
|
||||||
|
case VoicePacketMode.mode1300:
|
||||||
|
return Codec2Mode.mode1300;
|
||||||
|
case VoicePacketMode.mode2400:
|
||||||
|
return Codec2Mode.mode2400;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +79,5 @@ class VoiceCodecService {
|
|||||||
Future<Int16List> decodePackets(
|
Future<Int16List> decodePackets(
|
||||||
List<VoicePacket?> packets,
|
List<VoicePacket?> packets,
|
||||||
VoicePacketMode mode,
|
VoicePacketMode mode,
|
||||||
) =>
|
) => Future.error(UnsupportedError('Voice not supported on web'));
|
||||||
Future.error(UnsupportedError('Voice not supported on web'));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:audioplayers/audioplayers.dart';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
/// Plays decoded 8000 Hz / 16-bit mono PCM samples by writing a WAV file
|
/// Plays decoded mono PCM samples by writing a WAV file
|
||||||
/// to the system temp directory and using [AudioPlayer].
|
/// to the system temp directory and using [AudioPlayer].
|
||||||
class VoicePlayerService {
|
class VoicePlayerService {
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
@@ -49,16 +49,17 @@ class VoicePlayerService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Play [pcmSamples] (Int16, 8000 Hz, mono).
|
Future<void> play(Int16List pcmSamples, {required int sampleRateHz}) async {
|
||||||
Future<void> play(Int16List pcmSamples) async {
|
|
||||||
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
debugPrint('🔊 [VoicePlayer] play() called, ${pcmSamples.length} samples');
|
||||||
if (_isPlaying) await stop();
|
if (_isPlaying) await stop();
|
||||||
_position = Duration.zero;
|
_position = Duration.zero;
|
||||||
_duration = Duration(milliseconds: (pcmSamples.length * 1000) ~/ 8000);
|
_duration = Duration(
|
||||||
|
milliseconds: (pcmSamples.length * 1000) ~/ sampleRateHz,
|
||||||
|
);
|
||||||
_playbackStartedAt = DateTime.now();
|
_playbackStartedAt = DateTime.now();
|
||||||
_events.add(null);
|
_events.add(null);
|
||||||
|
|
||||||
final wavBytes = _buildWav(pcmSamples, sampleRate: 8000);
|
final wavBytes = _buildWav(pcmSamples, sampleRate: sampleRateHz);
|
||||||
final tmpDir = await getTemporaryDirectory();
|
final tmpDir = await getTemporaryDirectory();
|
||||||
final file = File('${tmpDir.path}/vc_voice.wav');
|
final file = File('${tmpDir.path}/vc_voice.wav');
|
||||||
await file.writeAsBytes(wavBytes);
|
await file.writeAsBytes(wavBytes);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:record/record.dart';
|
import 'package:record/record.dart';
|
||||||
|
|
||||||
/// Captures raw PCM audio at 8000 Hz, 16-bit mono.
|
/// Captures raw PCM audio at a codec-selected sample rate, 16-bit mono.
|
||||||
///
|
///
|
||||||
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
|
/// [startCapture] returns a [Stream<Int16List>] that emits chunks of PCM
|
||||||
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
|
/// samples every [chunkDuration]. Call [stopCapture] to end recording.
|
||||||
@@ -27,12 +27,16 @@ class VoiceRecorderService {
|
|||||||
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
/// [enableBandPassFilter] applies voice-tuned band-pass filtering when true.
|
||||||
/// [enableCompressor] normalizes speech dynamics before encoding.
|
/// [enableCompressor] normalizes speech dynamics before encoding.
|
||||||
/// [enableLimiter] protects against clipping peaks before encoding.
|
/// [enableLimiter] protects against clipping peaks before encoding.
|
||||||
/// The returned stream emits [Int16List] chunks that are ready for Codec2 encoding.
|
/// The returned stream emits [Int16List] chunks that are ready for voice encoding.
|
||||||
Stream<Int16List> startCapture({
|
Stream<Int16List> startCapture({
|
||||||
Duration chunkDuration = const Duration(seconds: 1),
|
Duration chunkDuration = const Duration(seconds: 1),
|
||||||
|
int sampleRateHz = 8000,
|
||||||
bool enableBandPassFilter = true,
|
bool enableBandPassFilter = true,
|
||||||
bool enableCompressor = true,
|
bool enableCompressor = true,
|
||||||
bool enableLimiter = true,
|
bool enableLimiter = true,
|
||||||
|
bool enableAutoGain = false,
|
||||||
|
bool enableEchoCancellation = false,
|
||||||
|
bool enableNoiseSuppression = false,
|
||||||
}) {
|
}) {
|
||||||
if (_isRecording) {
|
if (_isRecording) {
|
||||||
throw StateError('VoiceRecorderService: already recording');
|
throw StateError('VoiceRecorderService: already recording');
|
||||||
@@ -43,39 +47,59 @@ class VoiceRecorderService {
|
|||||||
|
|
||||||
_startRecording(
|
_startRecording(
|
||||||
chunkDuration,
|
chunkDuration,
|
||||||
|
sampleRateHz: sampleRateHz,
|
||||||
enableBandPassFilter: enableBandPassFilter,
|
enableBandPassFilter: enableBandPassFilter,
|
||||||
enableCompressor: enableCompressor,
|
enableCompressor: enableCompressor,
|
||||||
enableLimiter: enableLimiter,
|
enableLimiter: enableLimiter,
|
||||||
|
enableAutoGain: enableAutoGain,
|
||||||
|
enableEchoCancellation: enableEchoCancellation,
|
||||||
|
enableNoiseSuppression: enableNoiseSuppression,
|
||||||
);
|
);
|
||||||
return _controller!.stream;
|
return _controller!.stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _startRecording(
|
Future<void> _startRecording(
|
||||||
Duration chunkDuration, {
|
Duration chunkDuration, {
|
||||||
|
required int sampleRateHz,
|
||||||
required bool enableBandPassFilter,
|
required bool enableBandPassFilter,
|
||||||
required bool enableCompressor,
|
required bool enableCompressor,
|
||||||
required bool enableLimiter,
|
required bool enableLimiter,
|
||||||
|
required bool enableAutoGain,
|
||||||
|
required bool enableEchoCancellation,
|
||||||
|
required bool enableNoiseSuppression,
|
||||||
}) async {
|
}) async {
|
||||||
final config = const RecordConfig(
|
final useBandPassFilter = enableBandPassFilter;
|
||||||
|
final useCompressor = enableCompressor;
|
||||||
|
final useLimiter = enableLimiter;
|
||||||
|
final config = RecordConfig(
|
||||||
encoder: AudioEncoder.pcm16bits,
|
encoder: AudioEncoder.pcm16bits,
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
numChannels: 1,
|
numChannels: 1,
|
||||||
bitRate: 128000, // ignored for PCM, but required by API
|
bitRate: 128000, // ignored for PCM, but required by API
|
||||||
|
autoGain: enableAutoGain,
|
||||||
|
echoCancel: enableEchoCancellation,
|
||||||
|
noiseSuppress: enableNoiseSuppression,
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final stream = await _recorder.startStream(config);
|
final stream = await _recorder.startStream(config);
|
||||||
final voiceFilter = _VoiceBandPassFilter(
|
final voiceFilter = _VoiceBandPassFilter(
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
lowCutHz: 250.0,
|
lowCutHz: 250.0,
|
||||||
highCutHz: 3400.0,
|
highCutHz: 3400.0,
|
||||||
);
|
);
|
||||||
final dynamics = _VoiceDynamicsProcessor(
|
final dynamics = _VoiceDynamicsProcessor(
|
||||||
sampleRate: 8000,
|
sampleRate: sampleRateHz,
|
||||||
enableCompressor: enableCompressor,
|
thresholdDb: -18.0,
|
||||||
enableLimiter: enableLimiter,
|
ratio: 2.5,
|
||||||
|
attackMs: 8.0,
|
||||||
|
releaseMs: 120.0,
|
||||||
|
makeupGainDb: 4.0,
|
||||||
|
enableCompressor: useCompressor,
|
||||||
|
enableLimiter: useLimiter,
|
||||||
);
|
);
|
||||||
final chunkBytes = 8000 * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
final chunkBytes =
|
||||||
|
sampleRateHz * 2 * chunkDuration.inMilliseconds ~/ 1000;
|
||||||
final buffer = <int>[];
|
final buffer = <int>[];
|
||||||
|
|
||||||
_sub = stream.listen(
|
_sub = stream.listen(
|
||||||
@@ -85,9 +109,7 @@ class VoiceRecorderService {
|
|||||||
final chunk = buffer.sublist(0, chunkBytes);
|
final chunk = buffer.sublist(0, chunkBytes);
|
||||||
buffer.removeRange(0, chunkBytes);
|
buffer.removeRange(0, chunkBytes);
|
||||||
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
|
final pcm = _bytesToInt16(Uint8List.fromList(chunk));
|
||||||
final filtered = enableBandPassFilter
|
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
|
||||||
? voiceFilter.process(pcm)
|
|
||||||
: pcm;
|
|
||||||
_controller?.add(dynamics.process(filtered));
|
_controller?.add(dynamics.process(filtered));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -95,9 +117,7 @@ class VoiceRecorderService {
|
|||||||
if (buffer.isNotEmpty) {
|
if (buffer.isNotEmpty) {
|
||||||
final padded = _padToEven(buffer);
|
final padded = _padToEven(buffer);
|
||||||
final pcm = _bytesToInt16(Uint8List.fromList(padded));
|
final pcm = _bytesToInt16(Uint8List.fromList(padded));
|
||||||
final filtered = enableBandPassFilter
|
final filtered = useBandPassFilter ? voiceFilter.process(pcm) : pcm;
|
||||||
? voiceFilter.process(pcm)
|
|
||||||
: pcm;
|
|
||||||
_controller?.add(dynamics.process(filtered));
|
_controller?.add(dynamics.process(filtered));
|
||||||
}
|
}
|
||||||
_controller?.close();
|
_controller?.close();
|
||||||
@@ -163,17 +183,22 @@ class _VoiceDynamicsProcessor {
|
|||||||
|
|
||||||
_VoiceDynamicsProcessor({
|
_VoiceDynamicsProcessor({
|
||||||
required int sampleRate,
|
required int sampleRate,
|
||||||
|
required double thresholdDb,
|
||||||
|
required double ratio,
|
||||||
|
required double attackMs,
|
||||||
|
required double releaseMs,
|
||||||
|
required double makeupGainDb,
|
||||||
required bool enableCompressor,
|
required bool enableCompressor,
|
||||||
required bool enableLimiter,
|
required bool enableLimiter,
|
||||||
}) : _enableCompressor = enableCompressor,
|
}) : _enableCompressor = enableCompressor,
|
||||||
_enableLimiter = enableLimiter,
|
_enableLimiter = enableLimiter,
|
||||||
_compressor = _SimpleCompressor(
|
_compressor = _SimpleCompressor(
|
||||||
sampleRate: sampleRate.toDouble(),
|
sampleRate: sampleRate.toDouble(),
|
||||||
thresholdDb: -18.0,
|
thresholdDb: thresholdDb,
|
||||||
ratio: 2.5,
|
ratio: ratio,
|
||||||
attackMs: 8.0,
|
attackMs: attackMs,
|
||||||
releaseMs: 120.0,
|
releaseMs: releaseMs,
|
||||||
makeupGainDb: 4.0,
|
makeupGainDb: makeupGainDb,
|
||||||
),
|
),
|
||||||
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
_limiter = _PeakLimiter(ceilingDb: -1.0);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class AvatarLabelHelper {
|
class AvatarLabelHelper {
|
||||||
|
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
|
||||||
|
|
||||||
static String buildLabel(String name) {
|
static String buildLabel(String name) {
|
||||||
final trimmed = name.trim();
|
final trimmed = name.trim();
|
||||||
if (trimmed.isEmpty) return '?';
|
if (trimmed.isEmpty) return '?';
|
||||||
@@ -11,18 +13,23 @@ class AvatarLabelHelper {
|
|||||||
return '#${_take(hashBody, 2)}'.toUpperCase();
|
return '#${_take(hashBody, 2)}'.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
final parts = trimmed
|
final parts = _alnumChunks
|
||||||
.split(RegExp(r'[\s_-]+'))
|
.allMatches(trimmed)
|
||||||
|
.map((match) => match.group(0)!)
|
||||||
.where((part) => part.isNotEmpty)
|
.where((part) => part.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
final first = _take(parts[0], 1);
|
final first = _take(parts[0], 1);
|
||||||
final second = _take(parts[1], 1);
|
final second = _take(parts[1], 1);
|
||||||
return '$first$second'.toUpperCase();
|
return '$first$second'.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
return _take(trimmed, 2).toUpperCase();
|
return _take(parts.first, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _take(String value, int count) {
|
static String _take(String value, int count) {
|
||||||
|
|||||||
116
lib/utils/contact_grouping.dart
Normal file
116
lib/utils/contact_grouping.dart
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class InferredContactGroup {
|
||||||
|
final String key;
|
||||||
|
final String label;
|
||||||
|
final List<Contact> contacts;
|
||||||
|
|
||||||
|
const InferredContactGroup({
|
||||||
|
required this.key,
|
||||||
|
required this.label,
|
||||||
|
required this.contacts,
|
||||||
|
});
|
||||||
|
|
||||||
|
DateTime get latestSeen => contacts.first.lastSeenTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactListItem {
|
||||||
|
final Contact? contact;
|
||||||
|
final InferredContactGroup? group;
|
||||||
|
|
||||||
|
const ContactListItem._({this.contact, this.group});
|
||||||
|
|
||||||
|
const ContactListItem.contact(Contact contact) : this._(contact: contact);
|
||||||
|
|
||||||
|
const ContactListItem.group(InferredContactGroup group)
|
||||||
|
: this._(group: group);
|
||||||
|
|
||||||
|
bool get isGroup => group != null;
|
||||||
|
|
||||||
|
DateTime get latestSeen => group?.latestSeen ?? contact!.lastSeenTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactGrouping {
|
||||||
|
static final RegExp _prefixedNamePattern = RegExp(
|
||||||
|
r'^([A-Za-z0-9]{2,})([-_/:])',
|
||||||
|
);
|
||||||
|
|
||||||
|
static List<Contact> sortByLastSeen(List<Contact> contacts) {
|
||||||
|
return List<Contact>.from(contacts)
|
||||||
|
..sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ContactListItem> buildItems(
|
||||||
|
List<Contact> contacts, {
|
||||||
|
int minGroupSize = 4,
|
||||||
|
}) {
|
||||||
|
final sortedContacts = sortByLastSeen(contacts);
|
||||||
|
return buildItemsFromSorted(sortedContacts, minGroupSize: minGroupSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<ContactListItem> buildItemsFromSorted(
|
||||||
|
List<Contact> sortedContacts, {
|
||||||
|
int minGroupSize = 4,
|
||||||
|
}) {
|
||||||
|
final groupedContacts = <String, List<Contact>>{};
|
||||||
|
final groupLabels = <String, String>{};
|
||||||
|
|
||||||
|
for (final contact in sortedContacts) {
|
||||||
|
final prefix = _extractPrefix(contact.displayName);
|
||||||
|
if (prefix == null) continue;
|
||||||
|
groupedContacts.putIfAbsent(prefix.key, () => <Contact>[]).add(contact);
|
||||||
|
groupLabels.putIfAbsent(prefix.key, () => prefix.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
final eligibleGroups = <String, InferredContactGroup>{};
|
||||||
|
for (final entry in groupedContacts.entries) {
|
||||||
|
if (entry.value.length < minGroupSize) continue;
|
||||||
|
eligibleGroups[entry.key] = InferredContactGroup(
|
||||||
|
key: entry.key,
|
||||||
|
label: groupLabels[entry.key] ?? entry.key,
|
||||||
|
contacts: entry.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final emittedGroups = <String>{};
|
||||||
|
final items = <ContactListItem>[];
|
||||||
|
|
||||||
|
for (final contact in sortedContacts) {
|
||||||
|
final prefix = _extractPrefix(contact.displayName);
|
||||||
|
final group = prefix == null ? null : eligibleGroups[prefix.key];
|
||||||
|
if (group == null) {
|
||||||
|
items.add(ContactListItem.contact(contact));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (emittedGroups.add(group.key)) {
|
||||||
|
items.add(ContactListItem.group(group));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
static _GroupPrefix? _extractPrefix(String name) {
|
||||||
|
final trimmed = name.trim();
|
||||||
|
if (trimmed.isEmpty) return null;
|
||||||
|
|
||||||
|
final match = _prefixedNamePattern.firstMatch(trimmed);
|
||||||
|
if (match == null) return null;
|
||||||
|
|
||||||
|
final rawPrefix = match.group(1);
|
||||||
|
final separator = match.group(2);
|
||||||
|
if (rawPrefix == null || separator == null) return null;
|
||||||
|
|
||||||
|
return _GroupPrefix(
|
||||||
|
key: rawPrefix.toUpperCase(),
|
||||||
|
label: '$rawPrefix$separator',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GroupPrefix {
|
||||||
|
final String key;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _GroupPrefix({required this.key, required this.label});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
132
lib/utils/trace_node_resolver.dart
Normal file
132
lib/utils/trace_node_resolver.dart
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
|
||||||
|
class ResolvedTraceNode {
|
||||||
|
final MeshMapNode? node;
|
||||||
|
final int matchCount;
|
||||||
|
final bool usedOnlineFallback;
|
||||||
|
|
||||||
|
const ResolvedTraceNode({
|
||||||
|
required this.node,
|
||||||
|
required this.matchCount,
|
||||||
|
required this.usedOnlineFallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasMatch => node != null;
|
||||||
|
bool get isAmbiguous => matchCount > 1;
|
||||||
|
|
||||||
|
String? get matchSummary {
|
||||||
|
if (matchCount <= 1) return null;
|
||||||
|
final source = usedOnlineFallback ? 'online' : 'local';
|
||||||
|
return '$matchCount $source matches';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TraceNodeResolver {
|
||||||
|
static const Distance _distance = Distance();
|
||||||
|
|
||||||
|
const TraceNodeResolver._();
|
||||||
|
|
||||||
|
static ResolvedTraceNode resolveBest({
|
||||||
|
required List<MeshMapNode> nodes,
|
||||||
|
required Set<String> localPublicKeys,
|
||||||
|
required String? prefixHex,
|
||||||
|
LatLng? referenceA,
|
||||||
|
LatLng? referenceB,
|
||||||
|
String? preferredPrefix,
|
||||||
|
}) {
|
||||||
|
if (prefixHex == null || prefixHex.isEmpty) {
|
||||||
|
return const ResolvedTraceNode(
|
||||||
|
node: null,
|
||||||
|
matchCount: 0,
|
||||||
|
usedOnlineFallback: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final allMatches = nodes
|
||||||
|
.where((n) => n.publicKey.startsWith(prefixHex))
|
||||||
|
.toList();
|
||||||
|
if (allMatches.isEmpty) {
|
||||||
|
return const ResolvedTraceNode(
|
||||||
|
node: null,
|
||||||
|
matchCount: 0,
|
||||||
|
usedOnlineFallback: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final localMatches = allMatches
|
||||||
|
.where((node) => localPublicKeys.contains(node.publicKey))
|
||||||
|
.toList();
|
||||||
|
var pool = localMatches.isNotEmpty ? localMatches : allMatches;
|
||||||
|
final usedOnlineFallback = localMatches.isEmpty;
|
||||||
|
|
||||||
|
if (preferredPrefix != null && preferredPrefix.isNotEmpty) {
|
||||||
|
final preferredMatches = pool
|
||||||
|
.where((node) => node.publicKey.startsWith(preferredPrefix))
|
||||||
|
.toList();
|
||||||
|
if (preferredMatches.isNotEmpty) {
|
||||||
|
pool = preferredMatches;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.sort((a, b) {
|
||||||
|
final distanceCompare =
|
||||||
|
_scoreNode(
|
||||||
|
a,
|
||||||
|
referenceA: referenceA,
|
||||||
|
referenceB: referenceB,
|
||||||
|
).compareTo(
|
||||||
|
_scoreNode(b, referenceA: referenceA, referenceB: referenceB),
|
||||||
|
);
|
||||||
|
if (distanceCompare != 0) return distanceCompare;
|
||||||
|
return b.updatedAtMs.compareTo(a.updatedAtMs);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ResolvedTraceNode(
|
||||||
|
node: pool.first,
|
||||||
|
matchCount: pool.length,
|
||||||
|
usedOnlineFallback: usedOnlineFallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreNode(
|
||||||
|
MeshMapNode node, {
|
||||||
|
LatLng? referenceA,
|
||||||
|
LatLng? referenceB,
|
||||||
|
}) {
|
||||||
|
final point = LatLng(node.latitude, node.longitude);
|
||||||
|
if (referenceA != null && referenceB != null) {
|
||||||
|
return _distanceToSegmentMeters(point, referenceA, referenceB);
|
||||||
|
}
|
||||||
|
if (referenceA != null) {
|
||||||
|
return _distance.as(LengthUnit.Meter, point, referenceA);
|
||||||
|
}
|
||||||
|
if (referenceB != null) {
|
||||||
|
return _distance.as(LengthUnit.Meter, point, referenceB);
|
||||||
|
}
|
||||||
|
return double.maxFinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||||
|
final ax = a.longitude;
|
||||||
|
final ay = a.latitude;
|
||||||
|
final bx = b.longitude;
|
||||||
|
final by = b.latitude;
|
||||||
|
final px = p.longitude;
|
||||||
|
final py = p.latitude;
|
||||||
|
|
||||||
|
final abx = bx - ax;
|
||||||
|
final aby = by - ay;
|
||||||
|
final apx = px - ax;
|
||||||
|
final apy = py - ay;
|
||||||
|
final ab2 = abx * abx + aby * aby;
|
||||||
|
if (ab2 == 0) {
|
||||||
|
return _distance.as(LengthUnit.Meter, a, p);
|
||||||
|
}
|
||||||
|
var t = (apx * abx + apy * aby) / ab2;
|
||||||
|
t = t.clamp(0.0, 1.0);
|
||||||
|
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||||
|
return _distance.as(LengthUnit.Meter, closest, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,20 +11,41 @@ const int _defaultLoRaCrcEnabled = 1;
|
|||||||
const int _defaultLoRaExplicitHeader = 1;
|
const int _defaultLoRaExplicitHeader = 1;
|
||||||
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
const double _defaultAirtimeBudgetFactor = 1.0; // one half duty-cycle
|
||||||
|
|
||||||
/// Identifies which Codec2 mode was used for a voice packet.
|
enum VoiceCodecKind {
|
||||||
/// Matches the modeId byte in the text/binary packet header.
|
codec2(0, 'Codec2');
|
||||||
enum VoicePacketMode {
|
|
||||||
mode700c(0, '700C'),
|
|
||||||
mode1200(1, '1200'),
|
|
||||||
mode2400(2, '2400'),
|
|
||||||
mode1300(3, '1300'),
|
|
||||||
mode1400(4, '1400'),
|
|
||||||
mode1600(5, '1600'),
|
|
||||||
mode3200(6, '3200');
|
|
||||||
|
|
||||||
const VoicePacketMode(this.id, this.label);
|
const VoiceCodecKind(this.id, this.label);
|
||||||
final int id;
|
final int id;
|
||||||
final String label;
|
final String label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Identifies which voice codec/mode was used for a packet.
|
||||||
|
/// Matches the modeId byte in the text/binary packet header.
|
||||||
|
enum VoicePacketMode {
|
||||||
|
mode700c(0, '700C', VoiceCodecKind.codec2, 8000, 100, 1600),
|
||||||
|
mode1200(1, '1200', VoiceCodecKind.codec2, 8000, 150, 1040),
|
||||||
|
mode2400(2, '2400', VoiceCodecKind.codec2, 8000, 300, 520),
|
||||||
|
mode1300(3, '1300', VoiceCodecKind.codec2, 8000, 175, 880),
|
||||||
|
mode1400(4, '1400', VoiceCodecKind.codec2, 8000, 175, 880),
|
||||||
|
mode1600(5, '1600', VoiceCodecKind.codec2, 8000, 200, 800),
|
||||||
|
mode3200(6, '3200', VoiceCodecKind.codec2, 8000, 400, 400);
|
||||||
|
|
||||||
|
const VoicePacketMode(
|
||||||
|
this.id,
|
||||||
|
this.label,
|
||||||
|
this.codec,
|
||||||
|
this.sampleRateHz,
|
||||||
|
this.bytesPerSecond,
|
||||||
|
this.packetDurationMs,
|
||||||
|
);
|
||||||
|
final int id;
|
||||||
|
final String label;
|
||||||
|
final VoiceCodecKind codec;
|
||||||
|
final int sampleRateHz;
|
||||||
|
final int bytesPerSecond;
|
||||||
|
final int packetDurationMs;
|
||||||
|
|
||||||
|
int get samplesPerPacket => sampleRateHz * packetDurationMs ~/ 1000;
|
||||||
|
|
||||||
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
|
static VoicePacketMode fromId(int id) => VoicePacketMode.values.firstWhere(
|
||||||
(m) => m.id == id,
|
(m) => m.id == id,
|
||||||
@@ -153,23 +174,21 @@ class VoicePacket {
|
|||||||
|
|
||||||
/// Estimated audio duration of this packet in milliseconds.
|
/// Estimated audio duration of this packet in milliseconds.
|
||||||
int get durationMs {
|
int get durationMs {
|
||||||
// bytesPerSecond for each mode
|
try {
|
||||||
final bps = switch (mode) {
|
final bytesPerSecond = voiceModeBytesPerSecond(mode);
|
||||||
VoicePacketMode.mode700c => 100,
|
if (bytesPerSecond <= 0) return 0;
|
||||||
VoicePacketMode.mode1200 => 150,
|
return (codec2Data.length * 1000 ~/ bytesPerSecond).clamp(0, 1500);
|
||||||
VoicePacketMode.mode1300 => 175,
|
} catch (_) {
|
||||||
VoicePacketMode.mode1400 => 175,
|
// Be permissive with stale or malformed persisted voice metadata.
|
||||||
VoicePacketMode.mode1600 => 200,
|
return 0;
|
||||||
VoicePacketMode.mode2400 => 300,
|
}
|
||||||
VoicePacketMode.mode3200 => 400,
|
|
||||||
};
|
|
||||||
if (bps == 0) return 0;
|
|
||||||
return (codec2Data.length * 1000 ~/ bps).clamp(0, 1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]';
|
final suffix = total > 0
|
||||||
|
? ' ${mode.label} [$index/${total - 1}]'
|
||||||
|
: ' [$index]';
|
||||||
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
|
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,10 +263,10 @@ class VoiceEnvelope {
|
|||||||
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
|
int voiceModeBytesPerSecond(VoicePacketMode mode) => switch (mode) {
|
||||||
VoicePacketMode.mode700c => 100,
|
VoicePacketMode.mode700c => 100,
|
||||||
VoicePacketMode.mode1200 => 150,
|
VoicePacketMode.mode1200 => 150,
|
||||||
|
VoicePacketMode.mode2400 => 300,
|
||||||
VoicePacketMode.mode1300 => 175,
|
VoicePacketMode.mode1300 => 175,
|
||||||
VoicePacketMode.mode1400 => 175,
|
VoicePacketMode.mode1400 => 175,
|
||||||
VoicePacketMode.mode1600 => 200,
|
VoicePacketMode.mode1600 => 200,
|
||||||
VoicePacketMode.mode2400 => 300,
|
|
||||||
VoicePacketMode.mode3200 => 400,
|
VoicePacketMode.mode3200 => 400,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -419,8 +438,7 @@ class VoiceFetchRequest {
|
|||||||
this.version = 3,
|
this.version = 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isVoiceFetchRequestText(String text) =>
|
static bool isVoiceFetchRequestText(String text) => text.startsWith(_prefix);
|
||||||
text.startsWith(_prefix);
|
|
||||||
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
|
static bool isVoiceFetchRequestBinary(Uint8List payload) =>
|
||||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||||
|
|
||||||
@@ -473,9 +491,7 @@ class VoiceFetchRequest {
|
|||||||
final requesterKey6 = parts[2];
|
final requesterKey6 = parts[2];
|
||||||
final normalizedWant = wantToken == 'a'
|
final normalizedWant = wantToken == 'a'
|
||||||
? 'all'
|
? 'all'
|
||||||
: ((wantToken.startsWith('m'))
|
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
|
||||||
? 'missing'
|
|
||||||
: wantToken);
|
|
||||||
|
|
||||||
if (sid == null) {
|
if (sid == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -602,7 +618,11 @@ String _toBase36(int value) => value.toRadixString(36);
|
|||||||
|
|
||||||
String _encodeSessionId(String sessionIdHex) {
|
String _encodeSessionId(String sessionIdHex) {
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
|
||||||
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars');
|
throw ArgumentError.value(
|
||||||
|
sessionIdHex,
|
||||||
|
'sessionIdHex',
|
||||||
|
'Expected 8 hex chars',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final value = int.parse(sessionIdHex, radix: 16);
|
final value = int.parse(sessionIdHex, radix: 16);
|
||||||
return value.toRadixString(36);
|
return value.toRadixString(36);
|
||||||
@@ -616,10 +636,7 @@ String? _decodeSessionId(String token) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _encodeMissingIndicesCompact(List<int> indices) {
|
String _encodeMissingIndicesCompact(List<int> indices) {
|
||||||
final sorted = indices
|
final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
|
||||||
.where((v) => v >= 0 && v <= 254)
|
|
||||||
.toSet()
|
|
||||||
.toList()
|
|
||||||
..sort();
|
..sort();
|
||||||
if (sorted.isEmpty) return '';
|
if (sorted.isEmpty) return '';
|
||||||
final chunks = <String>[];
|
final chunks = <String>[];
|
||||||
@@ -632,7 +649,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
chunks.add(
|
chunks.add(
|
||||||
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}',
|
start == prev
|
||||||
|
? _toBase36(start)
|
||||||
|
: '${_toBase36(start)}-${_toBase36(prev)}',
|
||||||
);
|
);
|
||||||
start = curr;
|
start = curr;
|
||||||
prev = curr;
|
prev = curr;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class ContactAvatar extends StatelessWidget {
|
|||||||
final foregroundColor = _getForegroundColor(backgroundColor);
|
final foregroundColor = _getForegroundColor(backgroundColor);
|
||||||
final emoji = contact.roleEmoji;
|
final emoji = contact.roleEmoji;
|
||||||
|
|
||||||
if (emoji != null && emoji.isNotEmpty) {
|
if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) {
|
||||||
return _buildAvatarFrame(
|
return _buildAvatarFrame(
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
||||||
@@ -86,8 +86,16 @@ class ContactAvatar extends StatelessWidget {
|
|||||||
bool get _usesSquareShape =>
|
bool get _usesSquareShape =>
|
||||||
contact.type == ContactType.channel || contact.type == ContactType.room;
|
contact.type == ContactType.channel || contact.type == ContactType.room;
|
||||||
|
|
||||||
|
bool get _showsLeadingEmoji {
|
||||||
|
final emoji = contact.roleEmoji;
|
||||||
|
if (emoji == null || emoji.isEmpty) return false;
|
||||||
|
|
||||||
|
final effectiveName = (displayName ?? contact.displayName).trimLeft();
|
||||||
|
return effectiveName.startsWith(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
Color _getBackgroundColor(BuildContext context) {
|
Color _getBackgroundColor(BuildContext context) {
|
||||||
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
|
if (_shouldUseLabelFallback || _showsLeadingEmoji) {
|
||||||
return TrailColorService.getTrailColor(contact);
|
return TrailColorService.getTrailColor(contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -25,6 +26,10 @@ class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadLocalIPs() async {
|
Future<void> _loadLocalIPs() async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final Set<String> ipsSet = {};
|
final Set<String> ipsSet = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -35,16 +35,17 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
/// Validate channel name
|
/// Validate channel name
|
||||||
String? _validateName(String? value) {
|
String? _validateName(String? value) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final trimmedValue = value?.trim() ?? '';
|
||||||
|
|
||||||
if (value == null || value.trim().isEmpty) {
|
if (trimmedValue.isEmpty) {
|
||||||
return l10n.channelNameRequired;
|
return l10n.channelNameRequired;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value.length > 31) {
|
if (trimmedValue.length > 31) {
|
||||||
return l10n.channelNameTooLong;
|
return l10n.channelNameTooLong;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_isAscii(value)) {
|
if (!_isAscii(trimmedValue)) {
|
||||||
return l10n.invalidAsciiCharacters;
|
return l10n.invalidAsciiCharacters;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +106,8 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final isHashChannel = _nameController.text.startsWith('#');
|
final normalizedName = _nameController.text.trimLeft();
|
||||||
|
final isHashChannel = normalizedName.startsWith('#');
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(l10n.addChannel),
|
title: Text(l10n.addChannel),
|
||||||
@@ -163,8 +165,15 @@ class _AddChannelDialogState extends State<AddChannelDialog> {
|
|||||||
enabled: !_isCreating,
|
enabled: !_isCreating,
|
||||||
maxLength: 31,
|
maxLength: 31,
|
||||||
validator: _validateName,
|
validator: _validateName,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: isHashChannel
|
||||||
|
? TextInputAction.done
|
||||||
|
: TextInputAction.next,
|
||||||
onChanged: (_) => setState(() {}), // Rebuild to update icon
|
onChanged: (_) => setState(() {}), // Rebuild to update icon
|
||||||
|
onFieldSubmitted: (_) {
|
||||||
|
if (isHashChannel) {
|
||||||
|
_handleCreate();
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
// Channel Secret Field (only show for private channels)
|
// Channel Secret Field (only show for private channels)
|
||||||
if (!isHashChannel) ...[
|
if (!isHashChannel) ...[
|
||||||
|
|||||||
@@ -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
525
lib/widgets/contacts/contact_trace_sheet.dart
Normal file
525
lib/widgets/contacts/contact_trace_sheet.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (_) {
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (_) {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
179
test/screens/live_traffic_screen_test.dart
Normal file
179
test/screens/live_traffic_screen_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
143
test/services/contact_route_resolver_test.dart
Normal file
143
test/services/contact_route_resolver_test.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
147
test/services/live_traffic_summary_test.dart
Normal file
147
test/services/live_traffic_summary_test.dart
Normal 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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
97
test/services/mesh_map_nodes_service_test.dart
Normal file
97
test/services/mesh_map_nodes_service_test.dart
Normal 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
Reference in New Issue
Block a user