Compare commits

..

30 Commits

Author SHA1 Message Date
Janez T
99bb446ee9 Update iOS Runner project version 2026-03-11 16:31:53 +01:00
Janez T
088f3d37ef Update Portuguese wizard text 2026-03-11 16:07:38 +01:00
Janez T
7f200e5334 Update translations for all locales 2026-03-11 16:07:09 +01:00
Janez T
ae16417886 Resolve localization and name bugs 2026-03-11 15:54:00 +01:00
Janez T
467371946d Add channel slot detection fix 2026-03-11 15:30:31 +01:00
Janez T
2d23328ad0 Fix empty channel slot handling 2026-03-11 15:10:57 +01:00
Janez T
0dfb84bf4e Fix onboarding layout issues 2026-03-11 15:08:04 +01:00
Janez T
193a193e8a Ensure constant iOS location updates 2026-03-10 19:56:24 +01:00
Janez T
c1d1ec7d0c Enable iOS background GPS updates 2026-03-10 19:50:02 +01:00
Janez T
b415b63b4b Add GPX import export for trails 2026-03-10 13:52:37 +01:00
Janez T
818b9dbfb2 Review MeshCore payload status 2026-03-10 11:05:54 +01:00
Janez T
8085b89c07 Add repeaters map menu entry 2026-03-10 10:38:59 +01:00
Janez T
86771a3c55 Add fullscreen repeater map 2026-03-10 10:25:43 +01:00
Janez T
4b668ac2e9 Fix channel list layout behavior 2026-03-10 09:46:38 +01:00
Janez T
9deb563c05 Align and compact channel cards 2026-03-10 09:45:16 +01:00
Janez T
15ac3e2798 Align channel avatars and compact 2026-03-10 09:13:24 +01:00
Janez T
97addf7ba6 Hide last seen and tweak channels 2026-03-10 09:06:30 +01:00
Janez T
025bd737e7 Group contacts by last seen 2026-03-10 08:54:55 +01:00
Janez T
07b675dec6 Align repeater tile behavior 2026-03-10 08:48:04 +01:00
Janez T
d675ce18dd Use LogRxRouteDecoder in AppProvider 2026-03-10 08:43:58 +01:00
Janez T
a8e31bb2e8 Investigate DEVICE_QUERY timeout 2026-03-09 19:59:29 +01:00
Janez T
3cd96ba961 Fix scan command timeout 2026-03-09 19:53:36 +01:00
Janez T
a98fc6314e Analyze BLE MeshCore scan log 2026-03-09 19:45:17 +01:00
Janez T
ae1ea71f58 Analyze MeshCore BLE scan logs 2026-03-09 19:39:14 +01:00
Janez T
4dff79cd7b Update client ref and deps 2026-03-09 19:33:42 +01:00
Janez T
cbcb329c70 Fix NotificationService platform 2026-03-09 19:23:35 +01:00
Janez T
7abf96abf2 Point web build to meshcore-sar 2026-03-09 19:17:26 +01:00
Janez T
13bf74178c Update iOS TestFlight link 2026-03-09 16:31:42 +01:00
Janez T
66a00c9569 Remove sender name from notification 2026-03-08 21:12:08 +01:00
Janez T
b60fa008eb Fix ios notifications 2026-03-08 21:07:59 +01:00
78 changed files with 7733 additions and 1039 deletions

View File

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

View File

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

View File

@@ -489,7 +489,7 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 106; CURRENT_PROJECT_VERSION = 111;
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 = 111;
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 = 111;
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 = 111;
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 = 111;
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 = 111;
DEVELOPMENT_TEAM = JND55328G8; DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

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

View File

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

View File

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

View File

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

View File

@@ -2251,5 +2251,57 @@
"rawDump": "Ακατέργαστα δεδομένα", "rawDump": "Ακατέργαστα δεδομένα",
"cannotRetryMissingRecipient": "Δεν είναι δυνατή η επανάληψη: λείπουν πληροφορίες παραλήπτη", "cannotRetryMissingRecipient": "Δεν είναι δυνατή η επανάληψη: λείπουν πληροφορίες παραλήπτη",
"voiceUnavailable": "Η φωνή δεν είναι διαθέσιμη αυτή τη στιγμή", "voiceUnavailable": "Η φωνή δεν είναι διαθέσιμη αυτή τη στιγμή",
"requestingVoice": "Αίτηση φωνής" "requestingVoice": "Αίτηση φωνής",
"device": "συσκευή",
"change": "Αλλαγή",
"wizardOverviewDescription": "Αυτή η εφαρμογή συνδυάζει μηνύματα MeshCore, ενημερώσεις πεδίου SAR, χαρτογράφηση και εργαλεία συσκευής σε ένα σημείο.",
"wizardOverviewFeature1": "Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και μηνύματα καναλιού από την κύρια καρτέλα Μηνύματα.",
"wizardOverviewFeature2": "Μοιραστείτε δείκτες SAR, σχεδιάσεις χάρτη, ηχητικά αποσπάσματα και εικόνες μέσω του mesh.",
"wizardOverviewFeature3": "Συνδεθείτε μέσω BLE ή TCP και στη συνέχεια διαχειριστείτε το συνοδευτικό ραδιόφωνο μέσα από την εφαρμογή.",
"wizardMessagingTitle": "Μηνύματα και αναφορές πεδίου",
"wizardMessagingDescription": "Τα μηνύματα εδώ είναι κάτι περισσότερο από απλό κείμενο. Η εφαρμογή υποστηρίζει ήδη πολλαπλά επιχειρησιακά φορτία και ροές μεταφοράς.",
"wizardMessagingFeature1": "Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και κίνηση καναλιού από έναν μόνο συντάκτη.",
"wizardMessagingFeature2": "Δημιουργήστε ενημερώσεις SAR και επαναχρησιμοποιήσιμα πρότυπα SAR για συνηθισμένες αναφορές πεδίου.",
"wizardMessagingFeature3": "Μεταφέρετε φωνητικές συνεδρίες και εικόνες, με ένδειξη προόδου και εκτιμήσεις χρόνου εκπομπής στο περιβάλλον.",
"wizardConnectDeviceTitle": "Σύνδεση συσκευής",
"wizardConnectDeviceDescription": "Συνδέστε το ραδιόφωνο MeshCore, επιλέξτε όνομα και εφαρμόστε μια προρύθμιση ραδιοφώνου πριν συνεχίσετε.",
"wizardSetupBadge": "Ρύθμιση",
"wizardOverviewBadge": "Επισκόπηση",
"wizardConnectedToDevice": "Συνδέθηκε με το {deviceName}",
"wizardNoDeviceConnected": "Δεν έχει συνδεθεί ακόμη συσκευή",
"wizardSkipForNow": "Παράλειψη προς το παρόν",
"wizardDeviceNameLabel": "Όνομα συσκευής",
"wizardDeviceNameHelp": "Αυτό το όνομα ανακοινώνεται σε άλλους χρήστες του MeshCore.",
"wizardConfigRegionLabel": "Περιοχή ρύθμισης",
"wizardConfigRegionHelp": "Χρησιμοποιεί την πλήρη επίσημη λίστα προρυθμίσεων MeshCore. Η προεπιλογή είναι EU/UK (Narrow).",
"wizardPresetNote1": "Βεβαιωθείτε ότι η επιλεγμένη προρύθμιση ταιριάζει με τους τοπικούς ραδιοφωνικούς κανονισμούς.",
"wizardPresetNote2": "Η λίστα αντιστοιχεί στην επίσημη ροή προρυθμίσεων του εργαλείου ρύθμισης MeshCore.",
"wizardPresetNote3": "Το EU/UK (Narrow) παραμένει επιλεγμένο από προεπιλογή κατά την αρχική ρύθμιση.",
"wizardSaving": "Αποθήκευση...",
"wizardSaveAndContinue": "Αποθήκευση και συνέχεια",
"wizardEnterDeviceName": "Εισαγάγετε όνομα συσκευής πριν συνεχίσετε.",
"wizardDeviceSetupSaved": "Αποθηκεύτηκε το {deviceName} με {presetName}.",
"wizardNetworkTitle": "Επαφές, δωμάτια και αναμεταδότες",
"wizardNetworkDescription": "Η καρτέλα Επαφές οργανώνει το δίκτυο που ανακαλύπτετε και τις διαδρομές που μαθαίνετε με τον χρόνο.",
"wizardNetworkFeature1": "Ελέγξτε μέλη ομάδας, αναμεταδότες, δωμάτια, κανάλια και εκκρεμείς ανακοινώσεις σε μία λίστα.",
"wizardNetworkFeature2": "Χρησιμοποιήστε smart ping, σύνδεση σε δωμάτια, μαθημένες διαδρομές και εργαλεία επαναφοράς διαδρομών όταν η συνδεσιμότητα γίνεται χαοτική.",
"wizardNetworkFeature3": "Δημιουργήστε κανάλια και διαχειριστείτε προορισμούς δικτύου χωρίς να φύγετε από την εφαρμογή.",
"wizardMapOpsTitle": "Χάρτης, ίχνη και κοινόχρηστη γεωμετρία",
"wizardMapOpsDescription": "Ο χάρτης της εφαρμογής συνδέεται άμεσα με τα μηνύματα, την παρακολούθηση και τα επικαλύμματα SAR αντί να είναι ξεχωριστός προβολέας.",
"wizardMapOpsFeature1": "Παρακολουθήστε τη θέση σας, τις τοποθεσίες της ομάδας και τα ίχνη κίνησης στον χάρτη.",
"wizardMapOpsFeature2": "Ανοίξτε σχεδιάσεις από μηνύματα, προεπισκοπήστε τες επιτόπου και αφαιρέστε τες από τον χάρτη όταν χρειάζεται.",
"wizardMapOpsFeature3": "Χρησιμοποιήστε προβολές χάρτη αναμεταδοτών και κοινόχρηστα επικαλύμματα για να κατανοήσετε την εμβέλεια του δικτύου στο πεδίο.",
"wizardToolsTitle": "Εργαλεία πέρα από τα μηνύματα",
"wizardToolsDescription": "Υπάρχουν περισσότερα εδώ από τις τέσσερις κύριες καρτέλες. Η εφαρμογή περιλαμβάνει επίσης ρυθμίσεις, διαγνωστικά και προαιρετικές ροές αισθητήρων.",
"wizardToolsFeature1": "Ανοίξτε τη ρύθμιση συσκευής για να αλλάξετε παραμέτρους ραδιοφώνου, τηλεμετρία, ισχύ TX και στοιχεία συνοδευτικής συσκευής.",
"wizardToolsFeature2": "Ενεργοποιήστε την καρτέλα Αισθητήρες όταν θέλετε πίνακες παρακολούθησης και γρήγορες ενέργειες ανανέωσης.",
"wizardToolsFeature3": "Χρησιμοποιήστε καταγραφές πακέτων, σάρωση φάσματος και διαγνωστικά προγραμματιστή κατά την αντιμετώπιση προβλημάτων του mesh.",
"contactInSensors": "Στους αισθητήρες",
"contactAddToSensors": "Προσθήκη στους αισθητήρες",
"contactSetPath": "Ορισμός διαδρομής",
"contactAddedToSensors": "Το {contactName} προστέθηκε στους Αισθητήρες",
"contactFailedToClearRoute": "Αποτυχία εκκαθάρισης διαδρομής: {error}",
"contactRouteCleared": "Η διαδρομή εκκαθαρίστηκε",
"contactRouteSet": "Ορίστηκε διαδρομή: {route}",
"contactFailedToSetRoute": "Αποτυχία ορισμού διαδρομής: {error}"
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1741,4 +1741,199 @@ class AppLocalizationsEl extends AppLocalizations {
@override @override
String get requestingVoice => 'Αίτηση φωνής'; String get requestingVoice => 'Αίτηση φωνής';
@override
String get device => 'συσκευή';
@override
String get change => 'Αλλαγή';
@override
String get wizardOverviewDescription =>
'Αυτή η εφαρμογή συνδυάζει μηνύματα MeshCore, ενημερώσεις πεδίου SAR, χαρτογράφηση και εργαλεία συσκευής σε ένα σημείο.';
@override
String get wizardOverviewFeature1 =>
'Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και μηνύματα καναλιού από την κύρια καρτέλα Μηνύματα.';
@override
String get wizardOverviewFeature2 =>
'Μοιραστείτε δείκτες SAR, σχεδιάσεις χάρτη, ηχητικά αποσπάσματα και εικόνες μέσω του mesh.';
@override
String get wizardOverviewFeature3 =>
'Συνδεθείτε μέσω BLE ή TCP και στη συνέχεια διαχειριστείτε το συνοδευτικό ραδιόφωνο μέσα από την εφαρμογή.';
@override
String get wizardMessagingTitle => 'Μηνύματα και αναφορές πεδίου';
@override
String get wizardMessagingDescription =>
'Τα μηνύματα εδώ είναι κάτι περισσότερο από απλό κείμενο. Η εφαρμογή υποστηρίζει ήδη πολλαπλά επιχειρησιακά φορτία και ροές μεταφοράς.';
@override
String get wizardMessagingFeature1 =>
'Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και κίνηση καναλιού από έναν μόνο συντάκτη.';
@override
String get wizardMessagingFeature2 =>
'Δημιουργήστε ενημερώσεις SAR και επαναχρησιμοποιήσιμα πρότυπα SAR για συνηθισμένες αναφορές πεδίου.';
@override
String get wizardMessagingFeature3 =>
'Μεταφέρετε φωνητικές συνεδρίες και εικόνες, με ένδειξη προόδου και εκτιμήσεις χρόνου εκπομπής στο περιβάλλον.';
@override
String get wizardConnectDeviceTitle => 'Σύνδεση συσκευής';
@override
String get wizardConnectDeviceDescription =>
'Συνδέστε το ραδιόφωνο MeshCore, επιλέξτε όνομα και εφαρμόστε μια προρύθμιση ραδιοφώνου πριν συνεχίσετε.';
@override
String get wizardSetupBadge => 'Ρύθμιση';
@override
String get wizardOverviewBadge => 'Επισκόπηση';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Συνδέθηκε με το $deviceName';
}
@override
String get wizardNoDeviceConnected => 'Δεν έχει συνδεθεί ακόμη συσκευή';
@override
String get wizardSkipForNow => 'Παράλειψη προς το παρόν';
@override
String get wizardDeviceNameLabel => 'Όνομα συσκευής';
@override
String get wizardDeviceNameHelp =>
'Αυτό το όνομα ανακοινώνεται σε άλλους χρήστες του MeshCore.';
@override
String get wizardConfigRegionLabel => 'Περιοχή ρύθμισης';
@override
String get wizardConfigRegionHelp =>
'Χρησιμοποιεί την πλήρη επίσημη λίστα προρυθμίσεων MeshCore. Η προεπιλογή είναι EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Βεβαιωθείτε ότι η επιλεγμένη προρύθμιση ταιριάζει με τους τοπικούς ραδιοφωνικούς κανονισμούς.';
@override
String get wizardPresetNote2 =>
'Η λίστα αντιστοιχεί στην επίσημη ροή προρυθμίσεων του εργαλείου ρύθμισης MeshCore.';
@override
String get wizardPresetNote3 =>
'Το EU/UK (Narrow) παραμένει επιλεγμένο από προεπιλογή κατά την αρχική ρύθμιση.';
@override
String get wizardSaving => 'Αποθήκευση...';
@override
String get wizardSaveAndContinue => 'Αποθήκευση και συνέχεια';
@override
String get wizardEnterDeviceName =>
'Εισαγάγετε όνομα συσκευής πριν συνεχίσετε.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return 'Αποθηκεύτηκε το $deviceName με $presetName.';
}
@override
String get wizardNetworkTitle => 'Επαφές, δωμάτια και αναμεταδότες';
@override
String get wizardNetworkDescription =>
'Η καρτέλα Επαφές οργανώνει το δίκτυο που ανακαλύπτετε και τις διαδρομές που μαθαίνετε με τον χρόνο.';
@override
String get wizardNetworkFeature1 =>
'Ελέγξτε μέλη ομάδας, αναμεταδότες, δωμάτια, κανάλια και εκκρεμείς ανακοινώσεις σε μία λίστα.';
@override
String get wizardNetworkFeature2 =>
'Χρησιμοποιήστε smart ping, σύνδεση σε δωμάτια, μαθημένες διαδρομές και εργαλεία επαναφοράς διαδρομών όταν η συνδεσιμότητα γίνεται χαοτική.';
@override
String get wizardNetworkFeature3 =>
'Δημιουργήστε κανάλια και διαχειριστείτε προορισμούς δικτύου χωρίς να φύγετε από την εφαρμογή.';
@override
String get wizardMapOpsTitle => 'Χάρτης, ίχνη και κοινόχρηστη γεωμετρία';
@override
String get wizardMapOpsDescription =>
'Ο χάρτης της εφαρμογής συνδέεται άμεσα με τα μηνύματα, την παρακολούθηση και τα επικαλύμματα SAR αντί να είναι ξεχωριστός προβολέας.';
@override
String get wizardMapOpsFeature1 =>
'Παρακολουθήστε τη θέση σας, τις τοποθεσίες της ομάδας και τα ίχνη κίνησης στον χάρτη.';
@override
String get wizardMapOpsFeature2 =>
'Ανοίξτε σχεδιάσεις από μηνύματα, προεπισκοπήστε τες επιτόπου και αφαιρέστε τες από τον χάρτη όταν χρειάζεται.';
@override
String get wizardMapOpsFeature3 =>
'Χρησιμοποιήστε προβολές χάρτη αναμεταδοτών και κοινόχρηστα επικαλύμματα για να κατανοήσετε την εμβέλεια του δικτύου στο πεδίο.';
@override
String get wizardToolsTitle => 'Εργαλεία πέρα από τα μηνύματα';
@override
String get wizardToolsDescription =>
'Υπάρχουν περισσότερα εδώ από τις τέσσερις κύριες καρτέλες. Η εφαρμογή περιλαμβάνει επίσης ρυθμίσεις, διαγνωστικά και προαιρετικές ροές αισθητήρων.';
@override
String get wizardToolsFeature1 =>
'Ανοίξτε τη ρύθμιση συσκευής για να αλλάξετε παραμέτρους ραδιοφώνου, τηλεμετρία, ισχύ TX και στοιχεία συνοδευτικής συσκευής.';
@override
String get wizardToolsFeature2 =>
'Ενεργοποιήστε την καρτέλα Αισθητήρες όταν θέλετε πίνακες παρακολούθησης και γρήγορες ενέργειες ανανέωσης.';
@override
String get wizardToolsFeature3 =>
'Χρησιμοποιήστε καταγραφές πακέτων, σάρωση φάσματος και διαγνωστικά προγραμματιστή κατά την αντιμετώπιση προβλημάτων του mesh.';
@override
String get contactInSensors => 'Στους αισθητήρες';
@override
String get contactAddToSensors => 'Προσθήκη στους αισθητήρες';
@override
String get contactSetPath => 'Ορισμός διαδρομής';
@override
String contactAddedToSensors(String contactName) {
return 'Το $contactName προστέθηκε στους Αισθητήρες';
}
@override
String contactFailedToClearRoute(String error) {
return 'Αποτυχία εκκαθάρισης διαδρομής: $error';
}
@override
String get contactRouteCleared => 'Η διαδρομή εκκαθαρίστηκε';
@override
String contactRouteSet(String route) {
return 'Ορίστηκε διαδρομή: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Αποτυχία ορισμού διαδρομής: $error';
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1726,4 +1726,199 @@ class AppLocalizationsRu extends AppLocalizations {
@override @override
String get requestingVoice => 'Запрос голоса'; String get requestingVoice => 'Запрос голоса';
@override
String get device => 'устройство';
@override
String get change => 'Изменить';
@override
String get wizardOverviewDescription =>
'Это приложение объединяет сообщения MeshCore, полевые обновления SAR, карты и инструменты устройства в одном месте.';
@override
String get wizardOverviewFeature1 =>
'Отправляйте личные сообщения, сообщения комнат и сообщения каналов из основной вкладки «Сообщения».';
@override
String get wizardOverviewFeature2 =>
'Делитесь SAR-маркерами, рисунками на карте, голосовыми клипами и изображениями через mesh-сеть.';
@override
String get wizardOverviewFeature3 =>
'Подключайтесь по BLE или TCP, а затем управляйте сопутствующей радиостанцией прямо из приложения.';
@override
String get wizardMessagingTitle => 'Сообщения и полевые отчёты';
@override
String get wizardMessagingDescription =>
'Здесь сообщения это больше, чем просто текст. Приложение уже поддерживает несколько видов операционных данных и сценариев передачи.';
@override
String get wizardMessagingFeature1 =>
'Отправляйте личные сообщения, сообщения комнат и трафик каналов из одного редактора.';
@override
String get wizardMessagingFeature2 =>
'Создавайте обновления SAR и повторно используемые шаблоны SAR для типовых полевых отчётов.';
@override
String get wizardMessagingFeature3 =>
'Передавайте голосовые сессии и изображения с индикатором прогресса и оценкой эфирного времени в интерфейсе.';
@override
String get wizardConnectDeviceTitle => 'Подключить устройство';
@override
String get wizardConnectDeviceDescription =>
'Подключите свою радиостанцию MeshCore, выберите имя и примените радиопрофиль перед продолжением.';
@override
String get wizardSetupBadge => 'Настройка';
@override
String get wizardOverviewBadge => 'Обзор';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Подключено к $deviceName';
}
@override
String get wizardNoDeviceConnected => 'Устройство ещё не подключено';
@override
String get wizardSkipForNow => 'Пропустить пока';
@override
String get wizardDeviceNameLabel => 'Имя устройства';
@override
String get wizardDeviceNameHelp =>
'Это имя транслируется другим пользователям MeshCore.';
@override
String get wizardConfigRegionLabel => 'Регион конфигурации';
@override
String get wizardConfigRegionHelp =>
'Используется полный официальный список профилей MeshCore. По умолчанию выбран EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Убедитесь, что выбранный профиль соответствует местным радиоправилам.';
@override
String get wizardPresetNote2 =>
'Список соответствует официальному потоку профилей инструмента MeshCore config.';
@override
String get wizardPresetNote3 =>
'Для онбординга по умолчанию остаётся выбран EU/UK (Narrow).';
@override
String get wizardSaving => 'Сохранение...';
@override
String get wizardSaveAndContinue => 'Сохранить и продолжить';
@override
String get wizardEnterDeviceName =>
'Введите имя устройства перед продолжением.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return 'Сохранено: $deviceName, профиль $presetName.';
}
@override
String get wizardNetworkTitle => 'Контакты, комнаты и ретрансляторы';
@override
String get wizardNetworkDescription =>
'Вкладка «Контакты» организует сеть, которую вы обнаруживаете, и маршруты, которые вы узнаёте со временем.';
@override
String get wizardNetworkFeature1 =>
'Просматривайте участников команды, ретрансляторы, комнаты, каналы и ожидающие объявления в одном списке.';
@override
String get wizardNetworkFeature2 =>
'Используйте smart ping, вход в комнаты, изученные пути и инструменты сброса маршрутов, когда связь становится нестабильной.';
@override
String get wizardNetworkFeature3 =>
'Создавайте каналы и управляйте сетевыми назначениями, не покидая приложение.';
@override
String get wizardMapOpsTitle => 'Карта, следы и общая геометрия';
@override
String get wizardMapOpsDescription =>
'Карта приложения напрямую связана с сообщениями, отслеживанием и SAR-слоями, а не является отдельным просмотрщиком.';
@override
String get wizardMapOpsFeature1 =>
'Отслеживайте свою позицию, местоположение команды и траектории движения на карте.';
@override
String get wizardMapOpsFeature2 =>
'Открывайте рисунки из сообщений, просматривайте их прямо в интерфейсе и удаляйте с карты при необходимости.';
@override
String get wizardMapOpsFeature3 =>
'Используйте карты ретрансляторов и общие слои, чтобы понимать покрытие сети в поле.';
@override
String get wizardToolsTitle => 'Инструменты помимо сообщений';
@override
String get wizardToolsDescription =>
'Здесь есть больше, чем четыре основные вкладки. Приложение также включает настройку, диагностику и необязательные сценарии датчиков.';
@override
String get wizardToolsFeature1 =>
'Откройте настройки устройства, чтобы изменить параметры радио, телеметрию, мощность TX и сведения о сопутствующем устройстве.';
@override
String get wizardToolsFeature2 =>
'Включите вкладку «Датчики», если нужны панели наблюдаемых датчиков и быстрые действия обновления.';
@override
String get wizardToolsFeature3 =>
'Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.';
@override
String get contactInSensors => 'В датчиках';
@override
String get contactAddToSensors => 'Добавить в датчики';
@override
String get contactSetPath => 'Задать путь';
@override
String contactAddedToSensors(String contactName) {
return '$contactName добавлен в Датчики';
}
@override
String contactFailedToClearRoute(String error) {
return 'Не удалось очистить маршрут: $error';
}
@override
String get contactRouteCleared => 'Маршрут очищен';
@override
String contactRouteSet(String route) {
return 'Маршрут задан: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Не удалось задать маршрут: $error';
}
} }

View File

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

View File

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

View File

@@ -1732,4 +1732,199 @@ class AppLocalizationsUk extends AppLocalizations {
@override @override
String get requestingVoice => 'Запит голосу'; String get requestingVoice => 'Запит голосу';
@override
String get device => 'пристрій';
@override
String get change => 'Змінити';
@override
String get wizardOverviewDescription =>
'Ця програма поєднує повідомлення MeshCore, польові оновлення SAR, карти та інструменти пристрою в одному місці.';
@override
String get wizardOverviewFeature1 =>
'Надсилайте особисті повідомлення, повідомлення кімнат і повідомлення каналів з основної вкладки «Повідомлення».';
@override
String get wizardOverviewFeature2 =>
'Діліться SAR-маркерами, малюнками на карті, голосовими кліпами та зображеннями через mesh-мережу.';
@override
String get wizardOverviewFeature3 =>
'Підключайтеся через BLE або TCP, а потім керуйте супутнім радіопристроєм прямо з програми.';
@override
String get wizardMessagingTitle => 'Повідомлення та польові звіти';
@override
String get wizardMessagingDescription =>
'Повідомлення тут це більше, ніж просто текст. Програма вже підтримує кілька типів операційних даних і сценаріїв передачі.';
@override
String get wizardMessagingFeature1 =>
'Надсилайте особисті повідомлення, повідомлення кімнат і трафік каналів з одного редактора.';
@override
String get wizardMessagingFeature2 =>
'Створюйте оновлення SAR і багаторазові шаблони SAR для типових польових звітів.';
@override
String get wizardMessagingFeature3 =>
'Передавайте голосові сесії та зображення з індикатором прогресу й оцінками ефірного часу в інтерфейсі.';
@override
String get wizardConnectDeviceTitle => 'Підключити пристрій';
@override
String get wizardConnectDeviceDescription =>
'Підключіть своє радіо MeshCore, виберіть назву та застосуйте радіопрофіль перед продовженням.';
@override
String get wizardSetupBadge => 'Налаштування';
@override
String get wizardOverviewBadge => 'Огляд';
@override
String wizardConnectedToDevice(String deviceName) {
return 'Підключено до $deviceName';
}
@override
String get wizardNoDeviceConnected => 'Ще немає підключеного пристрою';
@override
String get wizardSkipForNow => 'Пропустити поки що';
@override
String get wizardDeviceNameLabel => 'Назва пристрою';
@override
String get wizardDeviceNameHelp =>
'Ця назва оголошується іншим користувачам MeshCore.';
@override
String get wizardConfigRegionLabel => 'Регіон конфігурації';
@override
String get wizardConfigRegionHelp =>
'Використовується повний офіційний список профілів MeshCore. Типово вибрано EU/UK (Narrow).';
@override
String get wizardPresetNote1 =>
'Переконайтеся, що вибраний профіль відповідає місцевим радіоправилам.';
@override
String get wizardPresetNote2 =>
'Список відповідає офіційному потоку профілів інструмента MeshCore config.';
@override
String get wizardPresetNote3 =>
'Для онбордингу типовим залишається EU/UK (Narrow).';
@override
String get wizardSaving => 'Збереження...';
@override
String get wizardSaveAndContinue => 'Зберегти й продовжити';
@override
String get wizardEnterDeviceName =>
'Введіть назву пристрою перед продовженням.';
@override
String wizardDeviceSetupSaved(String deviceName, String presetName) {
return 'Збережено $deviceName з профілем $presetName.';
}
@override
String get wizardNetworkTitle => 'Контакти, кімнати та ретранслятори';
@override
String get wizardNetworkDescription =>
'Вкладка «Контакти» організовує мережу, яку ви виявляєте, і маршрути, які вивчаєте з часом.';
@override
String get wizardNetworkFeature1 =>
'Переглядайте членів команди, ретранслятори, кімнати, канали та оголошення в очікуванні в одному списку.';
@override
String get wizardNetworkFeature2 =>
'Використовуйте smart ping, вхід до кімнат, вивчені шляхи й інструменти скидання маршрутів, коли зв\'язок стає нестабільним.';
@override
String get wizardNetworkFeature3 =>
'Створюйте канали та керуйте мережевими призначеннями, не виходячи з програми.';
@override
String get wizardMapOpsTitle => 'Мапа, сліди та спільна геометрія';
@override
String get wizardMapOpsDescription =>
'Мапа програми напряму пов\'язана з повідомленнями, відстеженням і SAR-накладками, а не є окремим переглядачем.';
@override
String get wizardMapOpsFeature1 =>
'Відстежуйте власну позицію, місця розташування команди та сліди руху на мапі.';
@override
String get wizardMapOpsFeature2 =>
'Відкривайте малюнки з повідомлень, переглядайте їх в інтерфейсі та видаляйте з мапи за потреби.';
@override
String get wizardMapOpsFeature3 =>
'Використовуйте мапи ретрансляторів і спільні накладки, щоб розуміти покриття мережі в полі.';
@override
String get wizardToolsTitle => 'Інструменти поза повідомленнями';
@override
String get wizardToolsDescription =>
'Тут є більше, ніж чотири основні вкладки. Програма також включає налаштування, діагностику та необов\'язкові сценарії датчиків.';
@override
String get wizardToolsFeature1 =>
'Відкрийте налаштування пристрою, щоб змінити параметри радіо, телеметрію, потужність TX і дані супутнього пристрою.';
@override
String get wizardToolsFeature2 =>
'Увімкніть вкладку «Датчики», коли потрібні панелі моніторингу та швидкі дії оновлення.';
@override
String get wizardToolsFeature3 =>
'Використовуйте журнали пакетів, сканування спектра та діагностику розробника для усунення проблем mesh-мережі.';
@override
String get contactInSensors => 'У датчиках';
@override
String get contactAddToSensors => 'Додати до датчиків';
@override
String get contactSetPath => 'Задати шлях';
@override
String contactAddedToSensors(String contactName) {
return '$contactName додано до Датчиків';
}
@override
String contactFailedToClearRoute(String error) {
return 'Не вдалося очистити маршрут: $error';
}
@override
String get contactRouteCleared => 'Маршрут очищено';
@override
String contactRouteSet(String route) {
return 'Маршрут задано: $route';
}
@override
String contactFailedToSetRoute(String error) {
return 'Не вдалося задати маршрут: $error';
}
} }

View File

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

View File

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

View File

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

View File

@@ -511,5 +511,57 @@
"rawDump": "Необработанные данные", "rawDump": "Необработанные данные",
"cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе", "cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе",
"voiceUnavailable": "Голос сейчас недоступен", "voiceUnavailable": "Голос сейчас недоступен",
"requestingVoice": "Запрос голоса" "requestingVoice": "Запрос голоса",
"device": "устройство",
"change": "Изменить",
"wizardOverviewDescription": "Это приложение объединяет сообщения MeshCore, полевые обновления SAR, карты и инструменты устройства в одном месте.",
"wizardOverviewFeature1": "Отправляйте личные сообщения, сообщения комнат и сообщения каналов из основной вкладки «Сообщения».",
"wizardOverviewFeature2": "Делитесь SAR-маркерами, рисунками на карте, голосовыми клипами и изображениями через mesh-сеть.",
"wizardOverviewFeature3": "Подключайтесь по BLE или TCP, а затем управляйте сопутствующей радиостанцией прямо из приложения.",
"wizardMessagingTitle": "Сообщения и полевые отчёты",
"wizardMessagingDescription": "Здесь сообщения это больше, чем просто текст. Приложение уже поддерживает несколько видов операционных данных и сценариев передачи.",
"wizardMessagingFeature1": "Отправляйте личные сообщения, сообщения комнат и трафик каналов из одного редактора.",
"wizardMessagingFeature2": "Создавайте обновления SAR и повторно используемые шаблоны SAR для типовых полевых отчётов.",
"wizardMessagingFeature3": "Передавайте голосовые сессии и изображения с индикатором прогресса и оценкой эфирного времени в интерфейсе.",
"wizardConnectDeviceTitle": "Подключить устройство",
"wizardConnectDeviceDescription": "Подключите свою радиостанцию MeshCore, выберите имя и примените радиопрофиль перед продолжением.",
"wizardSetupBadge": "Настройка",
"wizardOverviewBadge": "Обзор",
"wizardConnectedToDevice": "Подключено к {deviceName}",
"wizardNoDeviceConnected": "Устройство ещё не подключено",
"wizardSkipForNow": "Пропустить пока",
"wizardDeviceNameLabel": "Имя устройства",
"wizardDeviceNameHelp": "Это имя транслируется другим пользователям MeshCore.",
"wizardConfigRegionLabel": "Регион конфигурации",
"wizardConfigRegionHelp": "Используется полный официальный список профилей MeshCore. По умолчанию выбран EU/UK (Narrow).",
"wizardPresetNote1": "Убедитесь, что выбранный профиль соответствует местным радиоправилам.",
"wizardPresetNote2": "Список соответствует официальному потоку профилей инструмента MeshCore config.",
"wizardPresetNote3": "Для онбординга по умолчанию остаётся выбран EU/UK (Narrow).",
"wizardSaving": "Сохранение...",
"wizardSaveAndContinue": "Сохранить и продолжить",
"wizardEnterDeviceName": "Введите имя устройства перед продолжением.",
"wizardDeviceSetupSaved": "Сохранено: {deviceName}, профиль {presetName}.",
"wizardNetworkTitle": "Контакты, комнаты и ретрансляторы",
"wizardNetworkDescription": "Вкладка «Контакты» организует сеть, которую вы обнаруживаете, и маршруты, которые вы узнаёте со временем.",
"wizardNetworkFeature1": "Просматривайте участников команды, ретрансляторы, комнаты, каналы и ожидающие объявления в одном списке.",
"wizardNetworkFeature2": "Используйте smart ping, вход в комнаты, изученные пути и инструменты сброса маршрутов, когда связь становится нестабильной.",
"wizardNetworkFeature3": "Создавайте каналы и управляйте сетевыми назначениями, не покидая приложение.",
"wizardMapOpsTitle": "Карта, следы и общая геометрия",
"wizardMapOpsDescription": "Карта приложения напрямую связана с сообщениями, отслеживанием и SAR-слоями, а не является отдельным просмотрщиком.",
"wizardMapOpsFeature1": "Отслеживайте свою позицию, местоположение команды и траектории движения на карте.",
"wizardMapOpsFeature2": "Открывайте рисунки из сообщений, просматривайте их прямо в интерфейсе и удаляйте с карты при необходимости.",
"wizardMapOpsFeature3": "Используйте карты ретрансляторов и общие слои, чтобы понимать покрытие сети в поле.",
"wizardToolsTitle": "Инструменты помимо сообщений",
"wizardToolsDescription": "Здесь есть больше, чем четыре основные вкладки. Приложение также включает настройку, диагностику и необязательные сценарии датчиков.",
"wizardToolsFeature1": "Откройте настройки устройства, чтобы изменить параметры радио, телеметрию, мощность TX и сведения о сопутствующем устройстве.",
"wizardToolsFeature2": "Включите вкладку «Датчики», если нужны панели наблюдаемых датчиков и быстрые действия обновления.",
"wizardToolsFeature3": "Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.",
"contactInSensors": "В датчиках",
"contactAddToSensors": "Добавить в датчики",
"contactSetPath": "Задать путь",
"contactAddedToSensors": "{contactName} добавлен в Датчики",
"contactFailedToClearRoute": "Не удалось очистить маршрут: {error}",
"contactRouteCleared": "Маршрут очищен",
"contactRouteSet": "Маршрут задан: {route}",
"contactFailedToSetRoute": "Не удалось задать маршрут: {error}"
} }

View File

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

View File

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

View File

@@ -2276,5 +2276,57 @@
"voiceUnavailable": "Голос зараз недоступний", "voiceUnavailable": "Голос зараз недоступний",
"@voiceUnavailable": {}, "@voiceUnavailable": {},
"requestingVoice": "Запит голосу", "requestingVoice": "Запит голосу",
"@requestingVoice": {} "@requestingVoice": {},
"device": "пристрій",
"change": "Змінити",
"wizardOverviewDescription": "Ця програма поєднує повідомлення MeshCore, польові оновлення SAR, карти та інструменти пристрою в одному місці.",
"wizardOverviewFeature1": "Надсилайте особисті повідомлення, повідомлення кімнат і повідомлення каналів з основної вкладки «Повідомлення».",
"wizardOverviewFeature2": "Діліться SAR-маркерами, малюнками на карті, голосовими кліпами та зображеннями через mesh-мережу.",
"wizardOverviewFeature3": "Підключайтеся через BLE або TCP, а потім керуйте супутнім радіопристроєм прямо з програми.",
"wizardMessagingTitle": "Повідомлення та польові звіти",
"wizardMessagingDescription": "Повідомлення тут це більше, ніж просто текст. Програма вже підтримує кілька типів операційних даних і сценаріїв передачі.",
"wizardMessagingFeature1": "Надсилайте особисті повідомлення, повідомлення кімнат і трафік каналів з одного редактора.",
"wizardMessagingFeature2": "Створюйте оновлення SAR і багаторазові шаблони SAR для типових польових звітів.",
"wizardMessagingFeature3": "Передавайте голосові сесії та зображення з індикатором прогресу й оцінками ефірного часу в інтерфейсі.",
"wizardConnectDeviceTitle": "Підключити пристрій",
"wizardConnectDeviceDescription": "Підключіть своє радіо MeshCore, виберіть назву та застосуйте радіопрофіль перед продовженням.",
"wizardSetupBadge": "Налаштування",
"wizardOverviewBadge": "Огляд",
"wizardConnectedToDevice": "Підключено до {deviceName}",
"wizardNoDeviceConnected": "Ще немає підключеного пристрою",
"wizardSkipForNow": "Пропустити поки що",
"wizardDeviceNameLabel": "Назва пристрою",
"wizardDeviceNameHelp": "Ця назва оголошується іншим користувачам MeshCore.",
"wizardConfigRegionLabel": "Регіон конфігурації",
"wizardConfigRegionHelp": "Використовується повний офіційний список профілів MeshCore. Типово вибрано EU/UK (Narrow).",
"wizardPresetNote1": "Переконайтеся, що вибраний профіль відповідає місцевим радіоправилам.",
"wizardPresetNote2": "Список відповідає офіційному потоку профілів інструмента MeshCore config.",
"wizardPresetNote3": "Для онбордингу типовим залишається EU/UK (Narrow).",
"wizardSaving": "Збереження...",
"wizardSaveAndContinue": "Зберегти й продовжити",
"wizardEnterDeviceName": "Введіть назву пристрою перед продовженням.",
"wizardDeviceSetupSaved": "Збережено {deviceName} з профілем {presetName}.",
"wizardNetworkTitle": "Контакти, кімнати та ретранслятори",
"wizardNetworkDescription": "Вкладка «Контакти» організовує мережу, яку ви виявляєте, і маршрути, які вивчаєте з часом.",
"wizardNetworkFeature1": "Переглядайте членів команди, ретранслятори, кімнати, канали та оголошення в очікуванні в одному списку.",
"wizardNetworkFeature2": "Використовуйте smart ping, вхід до кімнат, вивчені шляхи й інструменти скидання маршрутів, коли зв'язок стає нестабільним.",
"wizardNetworkFeature3": "Створюйте канали та керуйте мережевими призначеннями, не виходячи з програми.",
"wizardMapOpsTitle": "Мапа, сліди та спільна геометрія",
"wizardMapOpsDescription": "Мапа програми напряму пов'язана з повідомленнями, відстеженням і SAR-накладками, а не є окремим переглядачем.",
"wizardMapOpsFeature1": "Відстежуйте власну позицію, місця розташування команди та сліди руху на мапі.",
"wizardMapOpsFeature2": "Відкривайте малюнки з повідомлень, переглядайте їх в інтерфейсі та видаляйте з мапи за потреби.",
"wizardMapOpsFeature3": "Використовуйте мапи ретрансляторів і спільні накладки, щоб розуміти покриття мережі в полі.",
"wizardToolsTitle": "Інструменти поза повідомленнями",
"wizardToolsDescription": "Тут є більше, ніж чотири основні вкладки. Програма також включає налаштування, діагностику та необов'язкові сценарії датчиків.",
"wizardToolsFeature1": "Відкрийте налаштування пристрою, щоб змінити параметри радіо, телеметрію, потужність TX і дані супутнього пристрою.",
"wizardToolsFeature2": "Увімкніть вкладку «Датчики», коли потрібні панелі моніторингу та швидкі дії оновлення.",
"wizardToolsFeature3": "Використовуйте журнали пакетів, сканування спектра та діагностику розробника для усунення проблем mesh-мережі.",
"contactInSensors": "У датчиках",
"contactAddToSensors": "Додати до датчиків",
"contactSetPath": "Задати шлях",
"contactAddedToSensors": "{contactName} додано до Датчиків",
"contactFailedToClearRoute": "Не вдалося очистити маршрут: {error}",
"contactRouteCleared": "Маршрут очищено",
"contactRouteSet": "Маршрут задано: {route}",
"contactFailedToSetRoute": "Не вдалося задати маршрут: {error}"
} }

View File

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

View File

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

View File

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

View File

@@ -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;
@@ -127,6 +128,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 +142,7 @@ class AppProvider with ChangeNotifier {
required this.imageProvider, required this.imageProvider,
}) { }) {
_setupCallbacks(); _setupCallbacks();
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
_initializeLocationTracking(); _initializeLocationTracking();
_loadMapEnabled(); _loadMapEnabled();
_loadContactsEnabled(); _loadContactsEnabled();
@@ -569,8 +574,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 =
({ ({
@@ -1658,12 +1675,42 @@ class AppProvider with ChangeNotifier {
); );
messagesProvider.syncDrawingsWithProvider(drawingProvider); messagesProvider.syncDrawingsWithProvider(drawingProvider);
_hasCompletedConnectionBootstrap = true;
_wasDeviceConnected = connectionProvider.deviceInfo.isConnected;
notifyListeners(); notifyListeners();
} catch (e) { } catch (e) {
debugPrint('Initialization error: $e'); debugPrint('Initialization error: $e');
} }
} }
Future<void> _syncAfterReconnect() async {
if (_isReconnectSyncInProgress ||
!connectionProvider.deviceInfo.isConnected) {
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();
debugPrint(
'📥 [AppProvider] Reconnect sync retrieved $messageCount message(s)',
);
} catch (e) {
debugPrint('❌ [AppProvider] Reconnect sync error: $e');
} finally {
_isReconnectSyncInProgress = false;
}
}
/// Automatically login to all rooms with saved passwords on cold connect /// Automatically login to all rooms with saved passwords on cold connect
Future<void> _autoLoginToRooms() async { Future<void> _autoLoginToRooms() async {
if (!connectionProvider.deviceInfo.isConnected) return; if (!connectionProvider.deviceInfo.isConnected) return;
@@ -1973,14 +2020,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 +2096,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 +2119,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 +2210,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 +2451,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 +2483,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 +2511,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 +2529,6 @@ class AppProvider with ChangeNotifier {
); );
return; return;
} }
if (!requester.routeSupportsLegacyRawTransport) {
return;
}
if (requester.outPath.isEmpty) { if (requester.outPath.isEmpty) {
return; return;
} }
@@ -2499,7 +2538,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 +2612,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();
@@ -2594,11 +2631,9 @@ class AppProvider with ChangeNotifier {
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
@@ -2648,6 +2683,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 +2696,10 @@ class AppProvider with ChangeNotifier {
); );
_stopLocationTracking(); _stopLocationTracking();
} }
if (isConnected && !wasConnected && _hasCompletedConnectionBootstrap) {
unawaited(_syncAfterReconnect());
}
} }
/// Start location tracking /// Start location tracking

View File

@@ -486,6 +486,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 +567,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 +848,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 +878,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 +1003,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 +1029,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
await _activeService.setChannel( // reliably answer the follow-up CMD_GET_CHANNEL verification (0x1F).
channelIdx: slotIdx, try {
channelName: channelName, await _activeService.setChannel(
secret: secretBytes, channelIdx: slotIdx,
); channelName: channelName,
secret: secretBytes,
);
} catch (e) {
if (_isChannelRefreshTimeoutError(e)) {
debugPrint(
'⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded',
);
onChannelInfoReceived?.call(
slotIdx,
channelName,
Uint8List.fromList(secretBytes),
null,
);
} else {
rethrow;
}
}
debugPrint( 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');

View File

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

View File

@@ -213,6 +213,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 +765,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 +789,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 +809,6 @@ class MessagesProvider with ChangeNotifier {
} }
} }
if (isChannelMessage) {
return '$senderName\n${message.text}';
}
return message.text; return message.text;
} }
@@ -889,6 +909,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);

View File

@@ -7,6 +7,11 @@ import '../models/contact.dart';
import '../providers/contacts_provider.dart'; import '../providers/contacts_provider.dart';
import '../providers/app_provider.dart'; import '../providers/app_provider.dart';
import '../providers/connection_provider.dart'; import '../providers/connection_provider.dart';
import '../providers/messages_provider.dart';
import '../services/message_destination_preferences.dart';
import '../utils/contact_grouping.dart';
import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart';
import '../widgets/contacts/contact_tile.dart'; import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart'; import '../widgets/contacts/add_channel_dialog.dart';
@@ -27,6 +32,11 @@ class ContactsTab extends StatefulWidget {
class _ContactsTabState extends State<ContactsTab> { class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition; Position? _currentPosition;
final Set<String> _resolvingAdvertKeys = <String>{}; final Set<String> _resolvingAdvertKeys = <String>{};
final Map<ContactSection, ContactSortMode> _sortModes = {
ContactSection.teamMembers: ContactSortMode.lastSeen,
ContactSection.repeaters: ContactSortMode.lastSeen,
ContactSection.rooms: ContactSortMode.lastSeen,
};
@override @override
void initState() { void initState() {
@@ -125,20 +135,31 @@ class _ContactsTabState extends State<ContactsTab> {
return l10n.daysAgo(diff.inDays); return l10n.daysAgo(diff.inDays);
} }
List<Contact> _sortContactsByDistance(List<Contact> contacts) { List<Contact> _sortContacts(List<Contact> contacts, ContactSection section) {
final sorted = List<Contact>.from(contacts); final sorted = List<Contact>.from(contacts);
if (section == ContactSection.channels) {
sorted.sort(
(a, b) =>
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
);
return sorted;
}
final sortMode = _sortModes[section] ?? ContactSortMode.lastSeen;
sorted.sort((a, b) { sorted.sort((a, b) {
final distanceA = _distanceFromCurrentPosition(a); if (sortMode == ContactSortMode.distance) {
final distanceB = _distanceFromCurrentPosition(b); final distanceA = _distanceFromCurrentPosition(a);
final distanceB = _distanceFromCurrentPosition(b);
if (distanceA != null && distanceB != null) { if (distanceA != null && distanceB != null) {
final distanceCompare = distanceA.compareTo(distanceB); final distanceCompare = distanceA.compareTo(distanceB);
if (distanceCompare != 0) return distanceCompare; if (distanceCompare != 0) return distanceCompare;
} else if (distanceA != null) { } else if (distanceA != null) {
return -1; return -1;
} else if (distanceB != null) { } else if (distanceB != null) {
return 1; return 1;
}
} }
return b.lastSeenTime.compareTo(a.lastSeenTime); return b.lastSeenTime.compareTo(a.lastSeenTime);
@@ -208,12 +229,23 @@ class _ContactsTabState extends State<ContactsTab> {
return Scaffold( return Scaffold(
body: Consumer<ContactsProvider>( body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) { builder: (context, contactsProvider, child) {
final chatContacts = _sortContactsByDistance( final messagesProvider = context.watch<MessagesProvider>();
final chatContacts = _sortContacts(
contactsProvider.chatContacts, contactsProvider.chatContacts,
ContactSection.teamMembers,
);
final repeaters = _sortContacts(
contactsProvider.repeaters,
ContactSection.repeaters,
);
final rooms = _sortContacts(
contactsProvider.rooms,
ContactSection.rooms,
);
final channels = _sortContacts(
contactsProvider.channels,
ContactSection.channels,
); );
final repeaters = _sortContactsByDistance(contactsProvider.repeaters);
final rooms = _sortContactsByDistance(contactsProvider.rooms);
final channels = _sortContactsByDistance(contactsProvider.channels);
final pendingAdverts = contactsProvider.pendingAdverts; final pendingAdverts = contactsProvider.pendingAdverts;
// Check if there are any displayable contacts // Check if there are any displayable contacts
@@ -282,17 +314,12 @@ class _ContactsTabState extends State<ContactsTab> {
title: l10n.teamMembers, title: l10n.teamMembers,
count: chatContacts.length, count: chatContacts.length,
icon: Icons.people, icon: Icons.people,
), trailing: _buildSortMenu(
...chatContacts.map( context,
(contact) => ContactTile( ContactSection.teamMembers,
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
), ),
), ),
..._buildContactSectionItems(chatContacts),
const Divider(height: 32), const Divider(height: 32),
], ],
@@ -302,17 +329,9 @@ class _ContactsTabState extends State<ContactsTab> {
title: l10n.repeaters, title: l10n.repeaters,
count: repeaters.length, count: repeaters.length,
icon: Icons.router, icon: Icons.router,
trailing: _buildSortMenu(context, ContactSection.repeaters),
), ),
...repeaters.map( ..._buildContactSectionItems(repeaters),
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
),
),
const Divider(height: 32), const Divider(height: 32),
], ],
@@ -322,17 +341,9 @@ class _ContactsTabState extends State<ContactsTab> {
title: l10n.rooms, title: l10n.rooms,
count: rooms.length, count: rooms.length,
icon: Icons.tag, icon: Icons.tag,
trailing: _buildSortMenu(context, ContactSection.rooms),
), ),
...rooms.map( ..._buildContactSectionItems(rooms),
(contact) => ContactTile(
contact: contact,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
),
),
const Divider(height: 32), const Divider(height: 32),
], ],
@@ -344,12 +355,10 @@ class _ContactsTabState extends State<ContactsTab> {
), ),
if (channels.isNotEmpty) ...[ if (channels.isNotEmpty) ...[
...channels.map( ...channels.map(
(contact) => ContactTile( (channel) => _ChannelActivityCard(
contact: contact, channel: channel,
currentPosition: _currentPosition, messagesProvider: messagesProvider,
calculateDistance: _calculateDistanceInMeters, contactsProvider: contactsProvider,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages, onNavigateToMessages: widget.onNavigateToMessages,
), ),
), ),
@@ -381,8 +390,97 @@ class _ContactsTabState extends State<ContactsTab> {
), ),
); );
} }
List<Widget> _buildContactSectionItems(List<Contact> contacts) {
final items = ContactGrouping.buildItemsFromSorted(contacts);
return items.map((item) {
if (item.isGroup) {
return _InferredContactGroupCard(
label: item.group!.label,
contacts: item.group!.contacts,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
);
}
return ContactTile(
contact: item.contact!,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
formatDistance: _formatDistance,
onNavigateToMap: widget.onNavigateToMap,
onNavigateToMessages: widget.onNavigateToMessages,
);
}).toList();
}
Widget _buildSortMenu(BuildContext context, ContactSection section) {
final l10n = AppLocalizations.of(context)!;
final selectedMode = _sortModes[section] ?? ContactSortMode.lastSeen;
final colorScheme = Theme.of(context).colorScheme;
return PopupMenuButton<ContactSortMode>(
tooltip: 'Sort',
initialValue: selectedMode,
onSelected: (sortMode) {
setState(() {
_sortModes[section] = sortMode;
});
},
itemBuilder: (context) => [
PopupMenuItem<ContactSortMode>(
value: ContactSortMode.lastSeen,
child: Row(
children: [
Icon(
Icons.schedule,
size: 18,
color: selectedMode == ContactSortMode.lastSeen
? colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text(l10n.lastSeen),
],
),
),
PopupMenuItem<ContactSortMode>(
value: ContactSortMode.distance,
child: Row(
children: [
Icon(
Icons.near_me,
size: 18,
color: selectedMode == ContactSortMode.distance
? colorScheme.primary
: null,
),
const SizedBox(width: 8),
Text(l10n.distance),
],
),
),
],
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.more_horiz,
size: 18,
color: colorScheme.onSurfaceVariant,
),
),
);
}
} }
enum ContactSortMode { lastSeen, distance }
enum ContactSection { teamMembers, repeaters, rooms, channels }
class _PendingAdvertTile extends StatelessWidget { class _PendingAdvertTile extends StatelessWidget {
final PendingAdvert advert; final PendingAdvert advert;
final String subtitle; final String subtitle;
@@ -427,11 +525,13 @@ class _SectionHeader extends StatelessWidget {
final String title; final String title;
final int count; final int count;
final IconData icon; final IconData icon;
final Widget? trailing;
const _SectionHeader({ const _SectionHeader({
required this.title, required this.title,
required this.count, required this.count,
required this.icon, required this.icon,
this.trailing,
}); });
@override @override
@@ -460,6 +560,454 @@ class _SectionHeader extends StatelessWidget {
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(context).textTheme.labelSmall,
), ),
), ),
if (trailing != null) ...[const Spacer(), trailing!],
],
),
);
}
}
class _InferredContactGroupCard extends StatelessWidget {
final String label;
final List<Contact> contacts;
final Position? currentPosition;
final double Function(double, double, double, double) calculateDistance;
final String Function(double) formatDistance;
final VoidCallback? onNavigateToMap;
final VoidCallback? onNavigateToMessages;
const _InferredContactGroupCard({
required this.label,
required this.contacts,
required this.currentPosition,
required this.calculateDistance,
required this.formatDistance,
required this.onNavigateToMap,
required this.onNavigateToMessages,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
initiallyExpanded: false,
leading: Icon(
Icons.folder_copy_outlined,
size: 18,
color: colorScheme.primary,
),
title: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(
contacts.length.toString(),
style: Theme.of(context).textTheme.labelSmall,
),
),
],
),
children: [
...contacts.map(
(contact) => ContactTile(
contact: contact,
currentPosition: currentPosition,
calculateDistance: calculateDistance,
formatDistance: formatDistance,
onNavigateToMap: onNavigateToMap,
onNavigateToMessages: onNavigateToMessages,
),
),
],
),
),
);
}
}
class _ChannelActivityCard extends StatelessWidget {
final Contact channel;
final MessagesProvider messagesProvider;
final ContactsProvider contactsProvider;
final VoidCallback? onNavigateToMessages;
const _ChannelActivityCard({
required this.channel,
required this.messagesProvider,
required this.contactsProvider,
required this.onNavigateToMessages,
});
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final titleStyle = Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
letterSpacing: -0.3,
);
final channelIdx = channel.publicKey.length > 1 ? channel.publicKey[1] : 0;
final channelMessages = messagesProvider.getMessagesForChannel(channelIdx)
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
final lastActivityAt = messagesProvider.getLastActivityForDestination(
channel,
);
final activityLabel = lastActivityAt == null
? null
: _formatRelativeTime(context, lastActivityAt);
final participantNames = <String>[];
for (final message in channelMessages) {
final senderName = message.senderName?.trim();
if (senderName == null || senderName.isEmpty) continue;
if (!participantNames.contains(senderName)) {
participantNames.add(senderName);
}
}
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surfaceContainerLow,
colorScheme.surfaceContainerHighest.withValues(alpha: 0.9),
],
),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.045),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () async {
await MessageDestinationPreferences.setDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKey: channel.publicKeyHex,
);
messagesProvider.navigateToDestination(
MessageDestinationPreferences.destinationTypeChannel,
recipientPublicKeyHex: channel.publicKeyHex,
);
onNavigateToMessages?.call();
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ContactAvatar(contact: channel, radius: 24),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
channel.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: titleStyle,
),
),
const SizedBox(width: 8),
if (activityLabel != null)
Text(
activityLabel,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8),
if (participantNames.isNotEmpty)
_ExpandableParticipantStack(
names: participantNames,
contactForName: _findParticipantContact,
)
else
Text(
'No recent chatters',
style: Theme.of(context).textTheme.bodySmall
?.copyWith(color: colorScheme.onSurfaceVariant),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_MetricChip(
icon: Icons.forum_outlined,
label: '${channelMessages.length}',
helper: 'messages',
),
_MetricChip(
icon: Icons.group_outlined,
label: '${participantNames.length}',
helper: 'active',
),
],
),
],
),
),
],
),
),
),
),
);
}
Contact? _findParticipantContact(String name) {
for (final contact in contactsProvider.contacts) {
if (!contact.isChannel && contact.advName == name) {
return contact;
}
}
return null;
}
}
class _ExpandableParticipantStack extends StatefulWidget {
final List<String> names;
final Contact? Function(String name) contactForName;
const _ExpandableParticipantStack({
required this.names,
required this.contactForName,
});
@override
State<_ExpandableParticipantStack> createState() =>
_ExpandableParticipantStackState();
}
class _ExpandableParticipantStackState
extends State<_ExpandableParticipantStack> {
bool _expanded = false;
static const int _collapsedVisibleCount = 4;
@override
Widget build(BuildContext context) {
final hasOverflow = widget.names.length > _collapsedVisibleCount;
final visibleNames = _expanded
? widget.names
: widget.names.take(_collapsedVisibleCount).toList();
final overflowCount = _expanded
? 0
: widget.names.length - visibleNames.length;
final spacing = _expanded ? 20.0 : 16.0;
const avatarSize = 24.0;
final itemCount = visibleNames.length + (overflowCount > 0 ? 1 : 0);
final width = itemCount == 0 ? 0.0 : avatarSize + (itemCount - 1) * spacing;
return GestureDetector(
onTap: hasOverflow
? () {
setState(() {
_expanded = !_expanded;
});
}
: null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
width: width,
height: avatarSize,
child: Stack(
clipBehavior: Clip.none,
children: [
for (var i = 0; i < visibleNames.length; i++)
Positioned(
left: i * spacing,
top: 0,
child: _ParticipantAvatar(
name: visibleNames[i],
contact: widget.contactForName(visibleNames[i]),
),
),
if (overflowCount > 0)
Positioned(
left: visibleNames.length * spacing,
top: 0,
child: _OverflowAvatar(count: overflowCount),
),
],
),
),
);
}
}
class _OverflowAvatar extends StatelessWidget {
final int count;
const _OverflowAvatar({required this.count});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 2),
),
alignment: Alignment.center,
child: Text(
'+$count',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
fontSize: 9,
),
),
);
}
}
class _ParticipantAvatar extends StatelessWidget {
final String name;
final Contact? contact;
static const double _size = 24;
const _ParticipantAvatar({required this.name, required this.contact});
@override
Widget build(BuildContext context) {
if (contact != null) {
final surfaceColor = Theme.of(context).colorScheme.surface;
return SizedBox(
width: _size,
height: _size,
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: surfaceColor, width: 2),
),
child: Padding(
padding: const EdgeInsets.all(2),
child: ClipOval(child: ContactAvatar(contact: contact!, radius: 8)),
),
),
);
}
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: _size,
height: _size,
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
shape: BoxShape.circle,
border: Border.all(color: colorScheme.surface, width: 2),
),
alignment: Alignment.center,
child: Text(
AvatarLabelHelper.buildLabel(name),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onTertiaryContainer,
fontSize: 9,
),
),
);
}
}
class _MetricChip extends StatelessWidget {
final IconData icon;
final String label;
final String helper;
const _MetricChip({
required this.icon,
required this.label,
required this.helper,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 5),
Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(width: 4),
Text(
helper,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
], ],
), ),
); );

View File

@@ -14,6 +14,7 @@ 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';
@@ -24,6 +25,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 +95,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 +198,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();
@@ -220,6 +222,9 @@ class _HomeScreenState extends State<HomeScreen>
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 +394,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 +410,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 +647,31 @@ class _HomeScreenState extends State<HomeScreen>
); );
} }
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 +772,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(
@@ -1037,17 +1071,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 +1091,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,
), ),
], ],
), ),

View File

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

View File

@@ -89,9 +89,7 @@ class _MessagesTabState extends State<MessagesTab> {
// Load saved message destination // Load saved message destination
_loadSavedDestination(); _loadSavedDestination();
_loadVoiceBitrate(); _loadVoiceBitrate();
// Mark all messages as read when tab is opened
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<MessagesProvider>().markAllAsRead();
_checkForNavigationRequest(); _checkForNavigationRequest();
}); });
} }
@@ -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) {
@@ -1641,6 +1676,13 @@ class _MessagesTabState extends State<MessagesTab> {
} }
} }
void _markCurrentDestinationAsRead() {
context.read<MessagesProvider>().markDestinationAsRead(
destinationType: _destinationType,
contact: _selectedRecipient,
);
}
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) { List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
// Get all recent messages // Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100); final allMessages = messagesProvider.getRecentMessages(count: 100);

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import 'dart:io' show Platform;
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart'; import 'package:flutter_avif/flutter_avif.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@@ -15,6 +16,7 @@ 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_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
@@ -72,6 +74,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();
@@ -89,6 +93,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadImagePreferences(); _loadImagePreferences();
_loadFastLocationSettings(); _loadFastLocationSettings();
_loadDeveloperMode(); _loadDeveloperMode();
_loadOnlineTraceCacheStatus();
} }
@override @override
@@ -126,6 +131,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);
@@ -470,7 +483,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 +842,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 +1120,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) =>
@@ -1213,8 +1321,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
]), ]),
_buildSectionHeader('Network Sharing'), if (!kIsWeb) ...[
const ConnectionModeSelector(), _buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
],
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection), _buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([ _buildSettingsCard([
@@ -1320,7 +1430,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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');
@@ -550,7 +553,7 @@ class NotificationService {
final title = localizations?.updateAvailable ?? 'App Update Available'; final title = localizations?.updateAvailable ?? 'App Update Available';
final body = localizations != null final body = localizations != null
? '${localizations.currentVersion}: $currentVersion\n' ? '${localizations.currentVersion}: $currentVersion\n'
'${localizations.latestVersion}: $latestVersion' '${localizations.latestVersion}: $latestVersion'
: 'Current: $currentVersion\nLatest: $latestVersion'; : 'Current: $currentVersion\nLatest: $latestVersion';
// Android notification details // Android notification details

View File

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

View File

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

View File

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

View File

@@ -4,11 +4,13 @@ import '../models/contact.dart';
class DecodedLogRxRoute { class DecodedLogRxRoute {
final int payloadType; final int payloadType;
final int pathDescriptor;
final List<int> pathBytes; final List<int> pathBytes;
final int hashSize; final int hashSize;
const DecodedLogRxRoute({ const DecodedLogRxRoute({
required this.payloadType, required this.payloadType,
required this.pathDescriptor,
required this.pathBytes, required this.pathBytes,
required this.hashSize, required this.hashSize,
}); });
@@ -63,21 +65,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;

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -9,11 +10,11 @@ import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart'; import '../../providers/contacts_provider.dart';
import '../../providers/map_provider.dart'; import '../../providers/map_provider.dart';
import '../../providers/messages_provider.dart'; import '../../providers/messages_provider.dart';
import '../../providers/sensors_provider.dart';
import '../../services/message_destination_preferences.dart'; import '../../services/message_destination_preferences.dart';
import 'contact_route_dialog.dart'; import 'contact_route_dialog.dart';
import 'room_login_sheet.dart'; import 'room_login_sheet.dart';
import '../common/contact_avatar.dart'; import '../common/contact_avatar.dart';
import '../../utils/location_formats.dart';
import '../../utils/toast_logger.dart'; import '../../utils/toast_logger.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
@@ -35,10 +36,9 @@ class ContactTile extends StatelessWidget {
this.onNavigateToMessages, this.onNavigateToMessages,
}); });
/// Get localized time since last seen String _getLocalizedRelativeTime(BuildContext context, DateTime when) {
String _getLocalizedTimeSinceLastSeen(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(contact.lastSeenTime); final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow; if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes); if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
@@ -48,6 +48,9 @@ class ContactTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isChannel = contact.type == ContactType.channel;
final isRoom = contact.type == ContactType.room;
final isRoomOrChannel = isRoom || isChannel;
final location = contact.displayLocation; final location = contact.displayLocation;
// Calculate distance if both positions are available // Calculate distance if both positions are available
String? distanceText; String? distanceText;
@@ -66,6 +69,7 @@ class ContactTile extends StatelessWidget {
// Get room login state if this is a room // Get room login state if this is a room
final connectionProvider = context.watch<ConnectionProvider>(); final connectionProvider = context.watch<ConnectionProvider>();
final messagesProvider = context.watch<MessagesProvider>();
final isPingInProgress = connectionProvider.isPingInProgress( final isPingInProgress = connectionProvider.isPingInProgress(
contact.publicKey, contact.publicKey,
); );
@@ -107,14 +111,20 @@ class ContactTile extends StatelessWidget {
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
letterSpacing: -0.3, letterSpacing: -0.3,
); );
final timeAgoText = _getLocalizedTimeSinceLastSeen(context); final lastActivityAt = isRoomOrChannel
? messagesProvider.getLastActivityForDestination(contact)
: null;
final timeAgoText = _getLocalizedRelativeTime(
context,
lastActivityAt ?? contact.lastSeenTime,
);
final timeAgoStyle = Theme.of(context).textTheme.labelSmall?.copyWith( final timeAgoStyle = Theme.of(context).textTheme.labelSmall?.copyWith(
color: contact.isRecentlySeen color: contact.isRecentlySeen
? colorScheme.primary ? colorScheme.primary
: colorScheme.onSurfaceVariant, : colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
final subtitleWidget = Column( final Widget subtitleWidget = Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (location != null) ...[ if (location != null) ...[
@@ -125,10 +135,8 @@ class ContactTile extends StatelessWidget {
longitude: location.longitude, longitude: location.longitude,
distanceText: distanceText, distanceText: distanceText,
), ),
if (contact.type != ContactType.channel) ...[ const SizedBox(height: 6),
const SizedBox(height: 6), Row(children: [_buildRoutePill(context, contact)]),
Row(children: [_buildRoutePill(context, contact)]),
],
] else ] else
Padding( Padding(
padding: const EdgeInsets.only(top: 4), padding: const EdgeInsets.only(top: 4),
@@ -237,7 +245,8 @@ class ContactTile extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(timeAgoText, style: timeAgoStyle), if (!isRoomOrChannel || lastActivityAt != null)
Text(timeAgoText, style: timeAgoStyle),
if (isPingInProgress) ...[ if (isPingInProgress) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
SizedBox( SizedBox(
@@ -264,11 +273,6 @@ class ContactTile extends StatelessWidget {
} }
void _handlePrimaryTap(BuildContext context, Contact contact) { void _handlePrimaryTap(BuildContext context, Contact contact) {
if (contact.type == ContactType.repeater) {
_showContactDetails(context, contact);
return;
}
_showContactActionSheet(context, contact); _showContactActionSheet(context, contact);
} }
@@ -280,6 +284,11 @@ class ContactTile extends StatelessWidget {
contact.type == ContactType.channel; contact.type == ContactType.channel;
final canSetPath = final canSetPath =
contact.type == ContactType.chat || contact.type == ContactType.room; contact.type == ContactType.chat || contact.type == ContactType.room;
final canAddToSensors =
contact.type == ContactType.chat ||
contact.type == ContactType.repeater;
final sensorsProvider = context.read<SensorsProvider>();
final isInSensors = sensorsProvider.isWatched(contact.publicKeyHex);
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -301,10 +310,54 @@ class ContactTile extends StatelessWidget {
await _openMessagesForContact(context, contact); await _openMessagesForContact(context, contact);
}, },
), ),
if (contact.displayLocation != null)
ListTile(
leading: const Icon(Icons.map_outlined),
title: Text(l10n.viewOnMap),
onTap: () {
Navigator.pop(sheetContext);
_showContactOnMap(context, contact);
},
),
if (contact.type == ContactType.room && !contact.isPublicChannel)
ListTile(
leading: const Icon(Icons.login),
title: Text(
context
.read<ConnectionProvider>()
.getRoomLoginState(contact.publicKeyPrefix)
?.isLoggedIn ==
true
? AppLocalizations.of(context)!.reLoginToRoom
: AppLocalizations.of(context)!.loginToRoom,
),
onTap: () {
Navigator.pop(sheetContext);
_showRoomLoginDialog(context, contact);
},
),
if (canAddToSensors)
ListTile(
leading: Icon(
isInSensors ? Icons.sensors : Icons.sensors_outlined,
),
title: Text(
isInSensors
? l10n.contactInSensors
: l10n.contactAddToSensors,
),
enabled: !isInSensors,
onTap: isInSensors
? null
: () async {
Navigator.pop(sheetContext);
await _addContactToSensors(context, contact);
},
),
if (canSetPath) if (canSetPath)
ListTile( ListTile(
leading: const Icon(Icons.alt_route), leading: const Icon(Icons.alt_route),
title: const Text('Set path'), title: Text(l10n.contactSetPath),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_showSetRouteDialog(context, contact); _showSetRouteDialog(context, contact);
@@ -317,13 +370,18 @@ class ContactTile extends StatelessWidget {
contact.isChannel ? l10n.deleteChannel : l10n.deleteContact, contact.isChannel ? l10n.deleteChannel : l10n.deleteContact,
style: const TextStyle(color: Colors.red), style: const TextStyle(color: Colors.red),
), ),
onTap: () { onTap: () async {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
if (contact.isChannel) { await Future<void>.delayed(Duration.zero);
_showDeleteChannelDialog(context, contact); if (!context.mounted) return;
} else { WidgetsBinding.instance.addPostFrameCallback((_) {
_showDeleteConfirmation(context, contact); if (!context.mounted) return;
} if (contact.isChannel) {
_showDeleteChannelDialog(context, contact);
} else {
_showDeleteConfirmation(context, contact);
}
});
}, },
), ),
], ],
@@ -332,6 +390,38 @@ class ContactTile extends StatelessWidget {
); );
} }
void _showContactOnMap(BuildContext context, Contact contact) {
final location = contact.displayLocation;
if (location == null) {
return;
}
context.read<MapProvider>().navigateToLocation(
location: LatLng(location.latitude, location.longitude),
);
onNavigateToMap?.call();
}
Future<void> _addContactToSensors(
BuildContext context,
Contact contact,
) async {
await context.read<SensorsProvider>().addSensor(contact);
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.contactAddedToSensors(contact.displayName),
),
),
);
}
Future<void> _openMessagesForContact( Future<void> _openMessagesForContact(
BuildContext context, BuildContext context,
Contact contact, Contact contact,
@@ -422,505 +512,6 @@ class ContactTile extends StatelessWidget {
} }
} }
void _showContactDetails(BuildContext context, Contact contact) {
final l10n = AppLocalizations.of(context)!;
// Get room login state
final connectionProvider = context.read<ConnectionProvider>();
final roomLoginState = contact.type == ContactType.room
? connectionProvider.getRoomLoginState(contact.publicKeyPrefix)
: null;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
final contactsProvider = context.watch<ContactsProvider>();
final currentContact =
contactsProvider.findContactByKey(contact.publicKey) ?? contact;
final isPingInProgress = context
.watch<ConnectionProvider>()
.isPingInProgress(contact.publicKey);
return Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.only(top: 8, bottom: 16),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2),
),
),
// Header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
ContactAvatar(contact: contact, radius: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
contact.displayName,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
const Divider(),
// Content
Expanded(
child: ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [
_detailRow(l10n.type, contact.type.displayName),
if (contact.isChannel) ...[
_detailRow(
l10n.channel,
contact.getLocalizedDisplayName(context),
),
if (!contact.isPublicChannel)
_detailRow(
'Slot',
'${l10n.channel} ${contact.publicKey.length > 1 ? contact.publicKey[1] : '-'}',
),
] else
// Public Key with copy button
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 100,
child: Text(
'${l10n.publicKey}:',
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
),
Expanded(child: Text(contact.publicKeyShort)),
const SizedBox(width: 8),
InkWell(
onTap: () {
Clipboard.setData(
ClipboardData(text: contact.publicKeyHex),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.publicKeyCopied),
duration: const Duration(seconds: 2),
),
);
},
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.copy,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
),
),
],
),
),
_detailRow(
l10n.lastSeen,
_getLocalizedTimeSinceLastSeen(context),
),
const SizedBox(height: 16),
// Room Login Status
if (roomLoginState != null) ...[
Text(
'${AppLocalizations.of(context)!.roomStatus}:',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
_detailRow(
AppLocalizations.of(context)!.loginStatus,
roomLoginState.isLoggedIn
? AppLocalizations.of(context)!.loggedIn
: AppLocalizations.of(context)!.notLoggedIn,
),
if (roomLoginState.isLoggedIn) ...[
_detailRow(
AppLocalizations.of(context)!.adminAccess,
roomLoginState.isAdmin
? AppLocalizations.of(context)!.yes
: AppLocalizations.of(context)!.no,
),
_detailRow(
AppLocalizations.of(context)!.permissions,
roomLoginState.permissions.toString(),
),
if (roomLoginState.loginDurationFormatted != null)
_detailRow(
AppLocalizations.of(context)!.loggedIn,
roomLoginState.loginDurationFormatted!,
),
],
_detailRow(
AppLocalizations.of(context)!.passwordSaved,
roomLoginState.hasPassword
? AppLocalizations.of(context)!.yes
: AppLocalizations.of(context)!.no,
),
const SizedBox(height: 16),
],
if (contact.displayLocation != null) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context)!.locationColon,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
TextButton.icon(
onPressed: () {
// Navigate to map and close modal
final mapProvider = context.read<MapProvider>();
mapProvider.navigateToLocation(
location: LatLng(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
);
Navigator.pop(context);
// Switch to map tab using callback
onNavigateToMap?.call();
},
icon: const Icon(Icons.map, size: 18),
label: Text(
AppLocalizations.of(context)!.viewOnMap,
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
),
),
],
),
const SizedBox(height: 8),
// Decimal Degrees (DD)
_detailRowWithCopy(
context,
'DD',
'${contact.displayLocation!.latitude.toStringAsFixed(6)}, ${contact.displayLocation!.longitude.toStringAsFixed(6)}',
),
// Degrees Minutes Seconds (DMS)
_detailRowWithCopy(
context,
'DMS',
_convertToDMS(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
// Degrees Decimal Minutes (DDM)
_detailRowWithCopy(
context,
'DDM',
_convertToDDM(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
// MGRS (Military Grid Reference System)
_detailRowWithCopy(
context,
'MGRS',
_convertToMGRS(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
// Google Plus Code
_detailRowWithCopy(
context,
'Plus Code',
formatPlusCode(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
),
),
const SizedBox(height: 16),
],
if (contact.telemetry != null) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${AppLocalizations.of(context)!.telemetry}:',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
TextButton.icon(
onPressed: isPingInProgress
? null
: () async {
final connectionProvider = context
.read<ConnectionProvider>();
final result = await connectionProvider
.smartPing(
contactPublicKey: contact.publicKey,
hasPath: contact.routeHasPath,
);
if (!context.mounted || result.success) {
return;
}
ToastLogger.error(
context,
AppLocalizations.of(
context,
)!.pingFailed(contact.displayName),
);
},
icon: isPingInProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.refresh, size: 18),
label: Text(AppLocalizations.of(context)!.refresh),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
),
),
],
),
const SizedBox(height: 8),
if (contact.telemetry!.batteryMilliVolts != null)
_detailRow(
AppLocalizations.of(context)!.voltage,
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
)
else if (contact.telemetry!.batteryPercentage != null)
_detailRow(
AppLocalizations.of(context)!.battery,
'${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%',
),
if (contact.telemetry!.temperature != null)
_detailRow(
AppLocalizations.of(context)!.temperature,
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
),
if (contact.telemetry!.humidity != null)
_detailRow(
AppLocalizations.of(context)!.humidity,
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
),
if (contact.telemetry!.pressure != null)
_detailRow(
AppLocalizations.of(context)!.pressure,
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
),
if (contact.telemetry!.gpsLocation != null)
_detailRow(
AppLocalizations.of(context)!.gpsTelemetry,
'${contact.telemetry!.gpsLocation!.latitude.toStringAsFixed(6)}, ${contact.telemetry!.gpsLocation!.longitude.toStringAsFixed(6)}',
),
_detailRow(
AppLocalizations.of(context)!.updated,
'${_formatTimestamp(contact.telemetry!.timestamp)} (${_formatTimeAgo(contact.telemetry!.timestamp)})',
),
],
if (!currentContact.isChannel) ...[
const SizedBox(height: 16),
Text(
'Route',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
_detailRow('Mode', currentContact.routeSummary),
if (currentContact.routeHopCount > 0)
_detailRow('Route', currentContact.routeCanonicalText),
if (currentContact.routeHopCount > 0)
_detailRow(
'Descriptor',
'0x${currentContact.routeEncodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
_showSetRouteDialog(context, currentContact),
icon: const Icon(Icons.route),
label: const Text('Set Route'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: currentContact.isPublicChannel
? null
: () async {
contactsProvider.resetContactRouteLocal(
currentContact.publicKey,
);
try {
await connectionProvider.resetPath(
currentContact.publicKey,
);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.pathResetInfo(
currentContact.displayName,
),
),
),
);
}
} catch (_) {
contactsProvider.setContactRouteLocal(
currentContact.publicKey,
signedEncodedPathLen:
currentContact.routeSignedPathLen,
paddedPathBytes:
currentContact.outPath,
);
if (context.mounted) {
ToastLogger.error(
context,
'Failed to reset route.',
);
}
}
},
icon: const Icon(Icons.refresh),
label: Text(
AppLocalizations.of(context)!.resetPath,
),
),
),
],
),
],
// Room Login button for room contacts (except Public Channel)
if (contact.type == ContactType.room &&
!contact.isPublicChannel) ...[
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context); // Close details first
_showRoomLoginDialog(context, contact);
},
icon: const Icon(Icons.login),
label: Text(
roomLoginState?.isLoggedIn == true
? AppLocalizations.of(context)!.reLoginToRoom
: AppLocalizations.of(context)!.loginToRoom,
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: _getTypeColor(
contact.type,
context,
),
foregroundColor: Colors.white,
),
),
),
],
// Delete Contact button (for all contact types except Public Channel)
if (!contact.isPublicChannel) ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
if (contact.isChannel) {
_showDeleteChannelDialog(
context,
contact,
closeDetailsSheetOnDelete: true,
);
return;
}
_showDeleteConfirmation(
context,
contact,
closeDetailsSheetOnDelete: true,
);
},
icon: const Icon(Icons.delete_outline),
label: Text(
contact.isChannel
? AppLocalizations.of(context)!.deleteChannel
: AppLocalizations.of(context)!.deleteContact,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: const BorderSide(color: Colors.red),
foregroundColor: Colors.red,
),
),
),
],
],
),
),
],
);
},
),
);
}
Future<void> _showSetRouteDialog( Future<void> _showSetRouteDialog(
BuildContext context, BuildContext context,
Contact contact, Contact contact,
@@ -953,15 +544,22 @@ class ContactTile extends StatelessWidget {
paddedPathBytes: previousPathBytes, paddedPathBytes: previousPathBytes,
); );
if (context.mounted) { if (context.mounted) {
ToastLogger.error(context, 'Failed to clear route: $error'); ToastLogger.error(
context,
AppLocalizations.of(
context,
)!.contactFailedToClearRoute(error.toString()),
);
} }
return; return;
} }
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(const SnackBar(content: Text('Route cleared'))); content: Text(AppLocalizations.of(context)!.contactRouteCleared),
),
);
} }
return; return;
} }
@@ -981,7 +579,13 @@ class ContactTile extends StatelessWidget {
); );
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Route set: ${parsedRoute.canonicalText}')), SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.contactRouteSet(parsedRoute.canonicalText),
),
),
); );
} }
} catch (error) { } catch (error) {
@@ -991,72 +595,16 @@ class ContactTile extends StatelessWidget {
paddedPathBytes: previousPathBytes, paddedPathBytes: previousPathBytes,
); );
if (context.mounted) { if (context.mounted) {
ToastLogger.error(context, 'Failed to set route: $error'); ToastLogger.error(
context,
AppLocalizations.of(
context,
)!.contactFailedToSetRoute(error.toString()),
);
} }
} }
} }
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Expanded(child: Text(value)),
],
),
);
}
Widget _detailRowWithCopy(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
Expanded(child: Text(value)),
const SizedBox(width: 8),
InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.copiedToClipboard(label),
),
duration: const Duration(seconds: 2),
),
);
},
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.copy,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
),
),
],
),
);
}
Widget _buildLocationMeta( Widget _buildLocationMeta(
BuildContext context, BuildContext context,
double latitude, double latitude,
@@ -1124,75 +672,6 @@ class ContactTile extends StatelessWidget {
); );
} }
/// Convert to Degrees Minutes Seconds (DMS) format
String _convertToDMS(double lat, double lon) {
String latDir = lat >= 0 ? 'N' : 'S';
String lonDir = lon >= 0 ? 'E' : 'W';
lat = lat.abs();
lon = lon.abs();
int latDeg = lat.floor();
double latMinDec = (lat - latDeg) * 60;
int latMin = latMinDec.floor();
double latSec = (latMinDec - latMin) * 60;
int lonDeg = lon.floor();
double lonMinDec = (lon - lonDeg) * 60;
int lonMin = lonMinDec.floor();
double lonSec = (lonMinDec - lonMin) * 60;
return '$latDeg°$latMin\'${latSec.toStringAsFixed(2)}"$latDir, $lonDeg°$lonMin\'${lonSec.toStringAsFixed(2)}"$lonDir';
}
/// Convert to Degrees Decimal Minutes (DDM) format
String _convertToDDM(double lat, double lon) {
String latDir = lat >= 0 ? 'N' : 'S';
String lonDir = lon >= 0 ? 'E' : 'W';
lat = lat.abs();
lon = lon.abs();
int latDeg = lat.floor();
double latMin = (lat - latDeg) * 60;
int lonDeg = lon.floor();
double lonMin = (lon - lonDeg) * 60;
return '$latDeg° ${latMin.toStringAsFixed(4)}\'$latDir, $lonDeg° ${lonMin.toStringAsFixed(4)}\'$lonDir';
}
/// Convert to MGRS (Military Grid Reference System) format
/// Simplified implementation - returns approximate grid zone
String _convertToMGRS(double lat, double lon) {
// Zone number (1-60)
int zone = ((lon + 180) / 6).floor() + 1;
// Zone letter (C-X, excluding I and O)
const letters = 'CDEFGHJKLMNPQRSTUVWX';
int letterIndex = ((lat + 80) / 8).floor();
if (letterIndex < 0) letterIndex = 0;
if (letterIndex >= letters.length) letterIndex = letters.length - 1;
String letter = letters[letterIndex];
// Simplified - just show zone designation
// Full MGRS would require UTM conversion library
return '$zone$letter (approximate)';
}
Color _getTypeColor(ContactType type, BuildContext context) {
switch (type) {
case ContactType.chat:
return Theme.of(context).colorScheme.primary;
case ContactType.repeater:
return Colors.green;
case ContactType.room:
return Colors.orange;
default:
return Colors.grey;
}
}
/// Get room login status color /// Get room login status color
Color _getRoomStatusColor(RoomLoginState state) { Color _getRoomStatusColor(RoomLoginState state) {
if (!state.isLoggedIn) { if (!state.isLoggedIn) {
@@ -1215,41 +694,6 @@ class ContactTile extends StatelessWidget {
return Icons.check; // Check for logged in (non-admin) return Icons.check; // Check for logged in (non-admin)
} }
String _formatTimestamp(DateTime timestamp) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final timestampDate = DateTime(
timestamp.year,
timestamp.month,
timestamp.day,
);
if (timestampDate == today) {
// Today - show time only
return '${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}:${timestamp.second.toString().padLeft(2, '0')}';
} else {
// Another day - show date and time
return '${timestamp.year}-${timestamp.month.toString().padLeft(2, '0')}-${timestamp.day.toString().padLeft(2, '0')} ${timestamp.hour.toString().padLeft(2, '0')}:${timestamp.minute.toString().padLeft(2, '0')}';
}
}
String _formatTimeAgo(DateTime timestamp) {
final now = DateTime.now();
final diff = now.difference(timestamp);
if (diff.inSeconds < 60) {
return '${diff.inSeconds}s ago';
} else if (diff.inMinutes < 60) {
return '${diff.inMinutes}m ago';
} else if (diff.inHours < 24) {
return '${diff.inHours}h ago';
} else if (diff.inDays == 1) {
return 'yesterday';
} else {
return '${diff.inDays}d ago';
}
}
/// Show delete channel confirmation dialog /// Show delete channel confirmation dialog
void _showDeleteChannelDialog( void _showDeleteChannelDialog(
BuildContext context, BuildContext context,

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ import '../../utils/tictactoe_message_parser.dart';
import '../../utils/location_formats.dart'; import '../../utils/location_formats.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../../utils/message_extensions.dart'; import '../../utils/message_extensions.dart';
import '../../utils/log_rx_route_decoder.dart';
import '../../models/message_transfer_details.dart'; import '../../models/message_transfer_details.dart';
import 'voice_message_bubble.dart'; import 'voice_message_bubble.dart';
import 'image_message_bubble.dart'; import 'image_message_bubble.dart';
@@ -67,6 +68,7 @@ class MessageBubble extends StatefulWidget {
} }
class _MessageBubbleState extends State<MessageBubble> { class _MessageBubbleState extends State<MessageBubble> {
static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]');
bool _isExpanded = false; bool _isExpanded = false;
bool _showReceivedStats = false; bool _showReceivedStats = false;
@@ -94,6 +96,72 @@ 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 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, vertical: 1),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: borderColor),
),
child: Text(
'@$mentionName',
style: baseBodyStyle?.copyWith(
color: textColor,
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
),
);
} 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 +1337,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 +1356,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) {
@@ -2232,7 +2296,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 +2385,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 &&

View File

@@ -1,6 +1,7 @@
// ignore_for_file: use_null_aware_elements // ignore_for_file: use_null_aware_elements
import 'dart:math' as math; import 'dart:math' as math;
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map; import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
@@ -60,7 +61,13 @@ 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(
@@ -516,13 +523,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,9 +537,11 @@ 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<MeshMapNode?> _matchNodesFromPathHashes({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,5 +22,13 @@ void main() {
test('keeps non-hash labels at two characters', () { test('keeps non-hash labels at two characters', () {
expect(AvatarLabelHelper.buildLabel('abc'), 'AB'); expect(AvatarLabelHelper.buildLabel('abc'), 'AB');
}); });
test('strips emoji when building fallback initials', () {
expect(AvatarLabelHelper.buildLabel('Charlie 🙂 Delta'), 'CD');
});
test('strips leading emoji when building fallback initials', () {
expect(AvatarLabelHelper.buildLabel('🙂 Charlie'), 'CH');
});
}); });
} }

View File

@@ -0,0 +1,120 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/utils/contact_grouping.dart';
void main() {
Contact buildContact({
required int seed,
required String name,
required DateTime lastSeen,
}) {
return Contact(
publicKey: Uint8List.fromList(
List<int>.generate(32, (index) => (seed + index) % 255),
),
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: name,
lastAdvert: lastSeen.millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: lastSeen.millisecondsSinceEpoch ~/ 1000,
);
}
group('ContactGrouping.buildItems', () {
test(
'groups prefixed contacts only when at least four share the prefix',
() {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'SI-4',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'OTHER',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
]);
expect(items, hasLength(2));
expect(items.first.isGroup, isTrue);
expect(items.first.group!.label, 'SI-');
expect(
items.first.group!.contacts.map((contact) => contact.displayName),
['SI-1', 'SI-2', 'SI-3', 'SI-4'],
);
expect(items.last.contact!.displayName, 'OTHER');
},
);
test('does not group when only three contacts share a prefix', () {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
buildContact(seed: 1, name: 'SI-1', lastSeen: now),
buildContact(
seed: 2,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(
seed: 3,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
]);
expect(items, hasLength(3));
expect(items.every((item) => !item.isGroup), isTrue);
});
test('orders groups and ungrouped contacts by latest last seen', () {
final now = DateTime(2026, 3, 10, 12);
final items = ContactGrouping.buildItems([
buildContact(
seed: 1,
name: 'Lone',
lastSeen: now.subtract(const Duration(minutes: 1)),
),
buildContact(seed: 2, name: 'SI-1', lastSeen: now),
buildContact(
seed: 3,
name: 'SI-2',
lastSeen: now.subtract(const Duration(minutes: 2)),
),
buildContact(
seed: 4,
name: 'SI-3',
lastSeen: now.subtract(const Duration(minutes: 3)),
),
buildContact(
seed: 5,
name: 'SI-4',
lastSeen: now.subtract(const Duration(minutes: 4)),
),
]);
expect(items, hasLength(2));
expect(items.first.isGroup, isTrue);
expect(items.last.contact!.displayName, 'Lone');
});
});
}

View File

@@ -24,12 +24,37 @@ void main() {
expect(decoded, isNotNull); expect(decoded, isNotNull);
expect(decoded!.payloadType, 0x01); expect(decoded!.payloadType, 0x01);
expect(decoded.pathDescriptor, 0x04);
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]); expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.hashSize, 2); expect(decoded.hashSize, 2);
expect(decoded.hopHashes, ['c2ba', '5fde']); expect(decoded.hopHashes, ['c2ba', '5fde']);
expect(decoded.originalSenderHashHex, 'c2ba'); expect(decoded.originalSenderHashHex, 'c2ba');
}); });
test('parses encoded descriptor with 2-byte hashes', () {
final packet = Uint8List.fromList([
0x88,
0x37,
0xae,
0x05,
0x42,
0xc2,
0xba,
0x5f,
0xde,
0x5c,
]);
final decoded = LogRxRouteDecoder.decode(packet);
expect(decoded, isNotNull);
expect(decoded!.pathDescriptor, 0x42);
expect(decoded.pathBytes, [0xc2, 0xba, 0x5f, 0xde]);
expect(decoded.hashSize, 2);
expect(decoded.hopCount, 2);
expect(decoded.hopHashes, ['c2ba', '5fde']);
});
test('uses preferred hash size when packet length is ambiguous', () { test('uses preferred hash size when packet length is ambiguous', () {
final packet = Uint8List.fromList([ final packet = Uint8List.fromList([
0x88, 0x88,

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/l10n/app_localizations.dart';
import 'package:meshcore_sar_app/widgets/contacts/add_channel_dialog.dart';
void main() {
Future<void> pumpDialog(
WidgetTester tester, {
required Future<void> Function(String name, String secret) onCreateChannel,
}) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: AddChannelDialog(onCreateChannel: onCreateChannel),
),
),
),
),
);
}
testWidgets('submits hash channels with an empty secret', (tester) async {
String? submittedName;
String? submittedSecret;
await pumpDialog(
tester,
onCreateChannel: (name, secret) async {
submittedName = name;
submittedSecret = secret;
},
);
await tester.enterText(find.byType(TextFormField).first, '#slovenia');
await tester.pump();
await tester.tap(find.text('Create Channel'));
await tester.pumpAndSettle();
expect(submittedName, '#slovenia');
expect(submittedSecret, '');
});
testWidgets('uses done action for hash channels', (tester) async {
String? submittedName;
await pumpDialog(
tester,
onCreateChannel: (name, secret) async {
submittedName = name;
},
);
await tester.enterText(find.byType(TextFormField).first, '#slovenia');
await tester.pump();
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(submittedName, '#slovenia');
});
}

View File

@@ -52,7 +52,7 @@ void main() {
contact.publicKeyHex: 3, contact.publicKeyHex: 3,
}, },
currentDestinationType: 'all', currentDestinationType: 'all',
onSelect: (_, __) {}, onSelect: (selectedContact, destinationType) {},
), ),
), ),
), ),