Compare commits

..

61 Commits

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

3
.gitignore vendored
View File

@@ -80,10 +80,11 @@ create_feature_graphic.py
# Tool caches and local state
.cachebro/
.osgrep/
third_party/lpcnet_flutter/
# Claude Code local settings (permissions, personal config)
.claude/settings.local.json
# Dart code coverage
coverage/
lcov.info
lcov.info

13
CHANGELOG.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -1586,5 +1586,57 @@
"rawDump": "Rohdaten",
"cannotRetryMissingRecipient": "Wiederholung nicht möglich: Empfängerinformationen fehlen",
"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": "Ακατέργαστα δεδομένα",
"cannotRetryMissingRecipient": "Δεν είναι δυνατή η επανάληψη: λείπουν πληροφορίες παραλήπτη",
"voiceUnavailable": "Η φωνή δεν είναι διαθέσιμη αυτή τη στιγμή",
"requestingVoice": "Αίτηση φωνής"
"requestingVoice": "Αίτηση φωνής",
"device": "συσκευή",
"change": "Αλλαγή",
"wizardOverviewDescription": "Αυτή η εφαρμογή συνδυάζει μηνύματα MeshCore, ενημερώσεις πεδίου SAR, χαρτογράφηση και εργαλεία συσκευής σε ένα σημείο.",
"wizardOverviewFeature1": "Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και μηνύματα καναλιού από την κύρια καρτέλα Μηνύματα.",
"wizardOverviewFeature2": "Μοιραστείτε δείκτες SAR, σχεδιάσεις χάρτη, ηχητικά αποσπάσματα και εικόνες μέσω του mesh.",
"wizardOverviewFeature3": "Συνδεθείτε μέσω BLE ή TCP και στη συνέχεια διαχειριστείτε το συνοδευτικό ραδιόφωνο μέσα από την εφαρμογή.",
"wizardMessagingTitle": "Μηνύματα και αναφορές πεδίου",
"wizardMessagingDescription": "Τα μηνύματα εδώ είναι κάτι περισσότερο από απλό κείμενο. Η εφαρμογή υποστηρίζει ήδη πολλαπλά επιχειρησιακά φορτία και ροές μεταφοράς.",
"wizardMessagingFeature1": "Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και κίνηση καναλιού από έναν μόνο συντάκτη.",
"wizardMessagingFeature2": "Δημιουργήστε ενημερώσεις SAR και επαναχρησιμοποιήσιμα πρότυπα SAR για συνηθισμένες αναφορές πεδίου.",
"wizardMessagingFeature3": "Μεταφέρετε φωνητικές συνεδρίες και εικόνες, με ένδειξη προόδου και εκτιμήσεις χρόνου εκπομπής στο περιβάλλον.",
"wizardConnectDeviceTitle": "Σύνδεση συσκευής",
"wizardConnectDeviceDescription": "Συνδέστε το ραδιόφωνο MeshCore, επιλέξτε όνομα και εφαρμόστε μια προρύθμιση ραδιοφώνου πριν συνεχίσετε.",
"wizardSetupBadge": "Ρύθμιση",
"wizardOverviewBadge": "Επισκόπηση",
"wizardConnectedToDevice": "Συνδέθηκε με το {deviceName}",
"wizardNoDeviceConnected": "Δεν έχει συνδεθεί ακόμη συσκευή",
"wizardSkipForNow": "Παράλειψη προς το παρόν",
"wizardDeviceNameLabel": "Όνομα συσκευής",
"wizardDeviceNameHelp": "Αυτό το όνομα ανακοινώνεται σε άλλους χρήστες του MeshCore.",
"wizardConfigRegionLabel": "Περιοχή ρύθμισης",
"wizardConfigRegionHelp": "Χρησιμοποιεί την πλήρη επίσημη λίστα προρυθμίσεων MeshCore. Η προεπιλογή είναι EU/UK (Narrow).",
"wizardPresetNote1": "Βεβαιωθείτε ότι η επιλεγμένη προρύθμιση ταιριάζει με τους τοπικούς ραδιοφωνικούς κανονισμούς.",
"wizardPresetNote2": "Η λίστα αντιστοιχεί στην επίσημη ροή προρυθμίσεων του εργαλείου ρύθμισης MeshCore.",
"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": {
},
"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",
"cannotRetryMissingRecipient": "No se puede reintentar: falta información del destinatario",
"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",
"cannotRetryMissingRecipient": "Impossible de réessayer : informations du destinataire manquantes",
"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",
"cannotRetryMissingRecipient": "Nije moguće ponoviti: nedostaju informacije o primatelju",
"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",
"cannotRetryMissingRecipient": "Impossibile riprovare: informazioni destinatario mancanti",
"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:
/// **'Requesting voice'**
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

View File

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

View File

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

View File

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

View File

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

View File

@@ -66,6 +66,11 @@ class DeviceInfo {
final int? advLat;
final int? advLon;
final bool? manualAddContacts;
final bool? autoAddUsers;
final bool? autoAddRepeaters;
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final int? radioFreq;
final int? radioBw;
final int? radioSf;
@@ -113,6 +118,11 @@ class DeviceInfo {
this.advLat,
this.advLon,
this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.radioFreq,
this.radioBw,
this.radioSf,
@@ -239,6 +249,11 @@ class DeviceInfo {
int? advLat,
int? advLon,
bool? manualAddContacts,
bool? autoAddUsers,
bool? autoAddRepeaters,
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? radioFreq,
int? radioBw,
int? radioSf,
@@ -278,6 +293,12 @@ class DeviceInfo {
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
autoAddOverwriteOldest:
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf,

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ import '../../providers/messages_provider.dart';
import '../../providers/sensors_provider.dart';
import '../../services/message_destination_preferences.dart';
import 'contact_route_dialog.dart';
import 'contact_trace_sheet.dart';
import 'room_login_sheet.dart';
import '../common/contact_avatar.dart';
import '../../utils/toast_logger.dart';
@@ -20,6 +21,8 @@ import '../../l10n/app_localizations.dart';
class ContactTile extends StatelessWidget {
final Contact contact;
final String? groupLabel;
final bool compact;
final Position? currentPosition;
final double Function(double, double, double, double)? calculateDistance;
final String Function(double)? formatDistance;
@@ -29,6 +32,8 @@ class ContactTile extends StatelessWidget {
const ContactTile({
super.key,
required this.contact,
this.groupLabel,
this.compact = false,
this.currentPosition,
this.calculateDistance,
this.formatDistance,
@@ -36,10 +41,9 @@ class ContactTile extends StatelessWidget {
this.onNavigateToMessages,
});
/// Get localized time since last seen
String _getLocalizedTimeSinceLastSeen(BuildContext context) {
String _getLocalizedRelativeTime(BuildContext context, DateTime when) {
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 < 60) return l10n.minutesAgo(diff.inMinutes);
@@ -50,6 +54,8 @@ class ContactTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isChannel = contact.type == ContactType.channel;
final isRoom = contact.type == ContactType.room;
final isRoomOrChannel = isRoom || isChannel;
final location = contact.displayLocation;
// Calculate distance if both positions are available
String? distanceText;
@@ -68,6 +74,7 @@ class ContactTile extends StatelessWidget {
// Get room login state if this is a room
final connectionProvider = context.watch<ConnectionProvider>();
final messagesProvider = context.watch<MessagesProvider>();
final isPingInProgress = connectionProvider.isPingInProgress(
contact.publicKey,
);
@@ -109,18 +116,37 @@ class ContactTile extends StatelessWidget {
fontWeight: FontWeight.w800,
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(
color: contact.isRecentlySeen
? colorScheme.primary
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
);
final Widget? subtitleWidget = isChannel
? null
final Widget subtitleWidget = compact
? _buildCompactSubtitle(context, distanceText)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (groupLabel case final label?)
_buildMetaPill(
context,
icon: Icons.folder_copy_outlined,
label: label,
),
],
),
if (location != null) ...[
const SizedBox(height: 2),
_buildLocationLine(
@@ -239,7 +265,7 @@ class ContactTile extends StatelessWidget {
),
),
const SizedBox(width: 8),
if (!isChannel)
if (!isRoomOrChannel || lastActivityAt != null)
Text(timeAgoText, style: timeAgoStyle),
if (isPingInProgress) ...[
const SizedBox(width: 6),
@@ -254,7 +280,7 @@ class ContactTile extends StatelessWidget {
],
],
),
?subtitleWidget,
subtitleWidget,
],
),
),
@@ -266,6 +292,28 @@ class ContactTile extends StatelessWidget {
);
}
Widget _buildCompactSubtitle(BuildContext context, String? distanceText) {
final location = contact.displayLocation;
final compactPills = <Widget>[
if (groupLabel case final label?)
_buildMetaPill(context, icon: Icons.folder_copy_outlined, label: label),
if (distanceText != null) _buildDistancePill(context, distanceText),
if (contact.routeHasPath && contact.routeHopCount > 0)
_buildRoutePill(context, contact),
if (location == null)
_buildMetaPill(
context,
icon: Icons.location_disabled_outlined,
label: AppLocalizations.of(context)!.noGpsData,
),
];
return Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(spacing: 6, runSpacing: 6, children: compactPills),
);
}
void _handlePrimaryTap(BuildContext context, Contact contact) {
_showContactActionSheet(context, contact);
}
@@ -277,7 +325,9 @@ class ContactTile extends StatelessWidget {
contact.type == ContactType.room ||
contact.type == ContactType.channel;
final canSetPath =
contact.type == ContactType.chat || contact.type == ContactType.room;
contact.type == ContactType.chat ||
contact.type == ContactType.room ||
contact.type == ContactType.repeater;
final canAddToSensors =
contact.type == ContactType.chat ||
contact.type == ContactType.repeater;
@@ -290,86 +340,115 @@ class ContactTile extends StatelessWidget {
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.message_outlined),
title: Text(l10n.messages),
enabled: canMessage,
onTap: !canMessage
? null
: () async {
Navigator.pop(sheetContext);
await _openMessagesForContact(context, contact);
},
),
if (contact.displayLocation != null)
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
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 ? 'In Sensors' : 'Add to Sensors'),
enabled: !isInSensors,
onTap: isInSensors
leading: const Icon(Icons.message_outlined),
title: Text(l10n.messages),
enabled: canMessage,
onTap: !canMessage
? null
: () async {
Navigator.pop(sheetContext);
await _addContactToSensors(context, contact);
await _openMessagesForContact(context, contact);
},
),
if (canSetPath)
ListTile(
leading: const Icon(Icons.alt_route),
title: const Text('Set path'),
onTap: () {
Navigator.pop(sheetContext);
_showSetRouteDialog(context, contact);
},
),
if (!contact.isPublicChannel)
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: Text(
contact.isChannel ? l10n.deleteChannel : l10n.deleteContact,
style: const TextStyle(color: Colors.red),
if (contact.displayLocation != null)
ListTile(
leading: const Icon(Icons.map_outlined),
title: Text(l10n.viewOnMap),
onTap: () {
Navigator.pop(sheetContext);
_showContactOnMap(context, contact);
},
),
onTap: () {
Navigator.pop(sheetContext);
if (contact.isChannel) {
_showDeleteChannelDialog(context, contact);
} else {
_showDeleteConfirmation(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)
ListTile(
leading: const Icon(Icons.alt_route),
title: Text(l10n.contactSetPath),
onTap: () {
Navigator.pop(sheetContext);
_showSetRouteDialog(context, contact);
},
),
if (!contact.isChannel)
ListTile(
leading: const Icon(Icons.route),
title: const Text('Trace'),
onTap: () {
Navigator.pop(sheetContext);
_showTraceSheet(context, contact);
},
),
if (!contact.isPublicChannel)
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Edit name'),
onTap: () {
Navigator.pop(sheetContext);
_showNameOverrideDialog(context, contact);
},
),
if (!contact.isPublicChannel)
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: Text(
contact.isChannel ? l10n.deleteChannel : l10n.deleteContact,
style: const TextStyle(color: Colors.red),
),
onTap: () async {
Navigator.pop(sheetContext);
await Future<void>.delayed(Duration.zero);
if (!context.mounted) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
if (contact.isChannel) {
_showDeleteChannelDialog(context, contact);
} else {
_showDeleteConfirmation(context, contact);
}
});
},
),
],
),
),
),
);
@@ -397,7 +476,13 @@ class ContactTile extends StatelessWidget {
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${contact.displayName} added to Sensors')),
SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.contactAddedToSensors(contact.displayName),
),
),
);
}
@@ -432,6 +517,18 @@ class ContactTile extends StatelessWidget {
);
}
void _showTraceSheet(BuildContext context, Contact contact) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => ContactTraceSheet(contact: contact),
);
}
void _showDeleteConfirmation(
BuildContext context,
Contact contact, {
@@ -491,6 +588,40 @@ class ContactTile extends StatelessWidget {
}
}
Future<void> _showNameOverrideDialog(
BuildContext context,
Contact contact,
) async {
final l10n = AppLocalizations.of(context)!;
final result = await showModalBottomSheet<String?>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (dialogContext) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(dialogContext).viewInsets.bottom,
),
child: _ContactNameOverrideSheet(
initialValue: contact.nameOverride ?? '',
advertisedName: contact.advName,
cancelLabel: l10n.cancel,
saveLabel: l10n.save,
),
),
),
);
if (result == null || !context.mounted) {
return;
}
context.read<ContactsProvider>().setContactNameOverride(
contact.publicKeyHex,
result,
);
}
Future<void> _showSetRouteDialog(
BuildContext context,
Contact contact,
@@ -523,15 +654,22 @@ class ContactTile extends StatelessWidget {
paddedPathBytes: previousPathBytes,
);
if (context.mounted) {
ToastLogger.error(context, 'Failed to clear route: $error');
ToastLogger.error(
context,
AppLocalizations.of(
context,
)!.contactFailedToClearRoute(error.toString()),
);
}
return;
}
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Route cleared')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.contactRouteCleared),
),
);
}
return;
}
@@ -541,6 +679,7 @@ class ContactTile extends StatelessWidget {
contact.publicKey,
signedEncodedPathLen: parsedRoute.signedEncodedPathLen,
paddedPathBytes: parsedRoute.paddedPathBytes,
inferredFallbackLocation: routeResult.inferredFallbackLocation,
);
try {
@@ -551,7 +690,13 @@ class ContactTile extends StatelessWidget {
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Route set: ${parsedRoute.canonicalText}')),
SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.contactRouteSet(parsedRoute.canonicalText),
),
),
);
}
} catch (error) {
@@ -561,7 +706,12 @@ class ContactTile extends StatelessWidget {
paddedPathBytes: previousPathBytes,
);
if (context.mounted) {
ToastLogger.error(context, 'Failed to set route: $error');
ToastLogger.error(
context,
AppLocalizations.of(
context,
)!.contactFailedToSetRoute(error.toString()),
);
}
}
}
@@ -610,6 +760,38 @@ class ContactTile extends StatelessWidget {
);
}
Widget _buildMetaPill(
BuildContext context, {
required IconData icon,
required String label,
bool monospace = false,
}) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 4),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
fontFamily: monospace ? 'monospace' : null,
),
),
],
),
);
}
Widget _buildLocationLine(
BuildContext context, {
required double latitude,
@@ -799,3 +981,105 @@ class ContactTile extends StatelessWidget {
);
}
}
class _ContactNameOverrideSheet extends StatefulWidget {
final String initialValue;
final String advertisedName;
final String cancelLabel;
final String saveLabel;
const _ContactNameOverrideSheet({
required this.initialValue,
required this.advertisedName,
required this.cancelLabel,
required this.saveLabel,
});
@override
State<_ContactNameOverrideSheet> createState() =>
_ContactNameOverrideSheetState();
}
class _ContactNameOverrideSheetState extends State<_ContactNameOverrideSheet> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _submit() {
Navigator.of(context).pop(_controller.text);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'Edit name',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _controller,
autofocus: true,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: 'Custom name',
hintText: widget.advertisedName,
helperText: 'Leave blank to use the advertised name.',
border: const OutlineInputBorder(),
),
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(widget.cancelLabel),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: _submit,
child: Text(widget.saveLabel),
),
),
],
),
],
),
),
);
}
}

View File

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

View File

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

View File

@@ -45,6 +45,7 @@ import 'system_message_bubble.dart';
/// - Grouped messages (expandable recipient list)
class MessageBubble extends StatefulWidget {
final Message message;
final int receivedCopies;
final VoidCallback? onTap;
final VoidCallback? onReply;
final bool isHighlighted;
@@ -56,6 +57,7 @@ class MessageBubble extends StatefulWidget {
const MessageBubble({
super.key,
required this.message,
this.receivedCopies = 1,
this.onTap,
this.onReply,
this.isHighlighted = false,
@@ -68,6 +70,7 @@ class MessageBubble extends StatefulWidget {
}
class _MessageBubbleState extends State<MessageBubble> {
static final RegExp _mentionPattern = RegExp(r'@\[(.+?)\]');
bool _isExpanded = false;
bool _showReceivedStats = false;
@@ -95,6 +98,74 @@ class _MessageBubbleState extends State<MessageBubble> {
});
}
Widget _buildMessageTextContent(String text, TextStyle? baseBodyStyle) {
final matches = _mentionPattern.allMatches(text).toList();
if (matches.isEmpty) {
return Text(text, style: baseBodyStyle);
}
final textColor =
baseBodyStyle?.color ?? Theme.of(context).colorScheme.onSurface;
final mentionFontSize = (baseBodyStyle?.fontSize ?? 14) - 1;
final backgroundColor = Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12);
final borderColor = Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.25);
final spans = <InlineSpan>[];
var cursor = 0;
for (final match in matches) {
if (match.start > cursor) {
spans.add(
TextSpan(
text: text.substring(cursor, match.start),
style: baseBodyStyle,
),
);
}
final mentionName = match.group(1)?.trim() ?? '';
if (mentionName.isNotEmpty) {
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: borderColor),
),
child: Text(
'@$mentionName',
style: baseBodyStyle?.copyWith(
color: textColor,
fontSize: mentionFontSize,
fontWeight: FontWeight.w600,
height: 1.0,
),
),
),
),
);
} else {
spans.add(TextSpan(text: match.group(0), style: baseBodyStyle));
}
cursor = match.end;
}
if (cursor < text.length) {
spans.add(TextSpan(text: text.substring(cursor), style: baseBodyStyle));
}
return Text.rich(TextSpan(children: spans, style: baseBodyStyle));
}
void _handleBubbleTap({required bool isSarMarker, required bool isDrawing}) {
if (!widget.message.isRead &&
!widget.message.isSentMessage &&
@@ -507,6 +578,7 @@ class _MessageBubbleState extends State<MessageBubble> {
'Sender to receipt ms: ${receptionDetails?.senderToReceiptMs ?? '-'}',
'Estimated transmit ms: ${receptionDetails?.estimatedTransmitMs ?? '-'}',
'Post-transmit delay ms: ${receptionDetails?.postTransmitDelayMs ?? '-'}',
'Received copies: ${widget.receivedCopies}',
'Expected ACK tag: ${widget.message.expectedAckTag ?? '-'}',
'Suggested timeout ms: ${widget.message.suggestedTimeoutMs ?? '-'}',
'Round-trip ms: ${widget.message.roundTripTimeMs ?? '-'}',
@@ -777,6 +849,12 @@ class _MessageBubbleState extends State<MessageBubble> {
receptionDetails!.postTransmitDelayMs!,
),
),
if (widget.receivedCopies > 1)
_detailRow(
context,
label: 'Received copies',
value: '${widget.receivedCopies}',
),
if (widget.message.suggestedTimeoutMs != null)
_detailRow(
context,
@@ -1721,6 +1799,7 @@ class _MessageBubbleState extends State<MessageBubble> {
receptionDetails?.rssiDbm ??
matchedRxLog?.logRxDataInfo?.rssiDbm ??
message.lastEchoRssiDbm;
final routeMetadata = messagesProvider.getMessageRouteMetadata(message.id);
// Look up contact information for rich display name
final contactsProvider = context.read<ContactsProvider>();
@@ -2004,9 +2083,11 @@ class _MessageBubbleState extends State<MessageBubble> {
Expanded(
child: Text(
displayName,
style: Theme.of(context).textTheme.labelMedium
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
fontSize: 12,
fontWeight: FontWeight.bold,
height: 1.1,
color: isOwnMessage
? Theme.of(context).colorScheme.primary
: null,
@@ -2229,7 +2310,10 @@ class _MessageBubbleState extends State<MessageBubble> {
);
if (drawing == null) {
return Text(message.text, style: baseBodyStyle);
return _buildMessageTextContent(
message.text,
baseBodyStyle,
);
}
final String drawingTypeLabel;
@@ -2315,7 +2399,7 @@ class _MessageBubbleState extends State<MessageBubble> {
)
// Regular message content
else if (!message.isDrawing || widget.isCompact)
Text(message.text, style: baseBodyStyle),
_buildMessageTextContent(message.text, baseBodyStyle),
if (!widget.isCompact &&
!isSarMarker &&
@@ -2445,7 +2529,7 @@ class _MessageBubbleState extends State<MessageBubble> {
switch (recipient.deliveryStatus) {
case MessageDeliveryStatus.delivered:
statusColor = Colors.green;
statusIcon = Icons.check_circle;
statusIcon = Icons.done_all;
statusText =
recipient.roundTripTimeMs != null
? '${recipient.roundTripTimeMs}ms'
@@ -2453,6 +2537,15 @@ class _MessageBubbleState extends State<MessageBubble> {
context,
)!.delivered;
break;
case MessageDeliveryStatus.sent:
statusColor = Theme.of(
context,
).colorScheme.onSurfaceVariant;
statusIcon = Icons.done;
statusText = AppLocalizations.of(
context,
)!.sent;
break;
case MessageDeliveryStatus.failed:
statusColor = Colors.red;
statusIcon = Icons.cancel;
@@ -2461,7 +2554,6 @@ class _MessageBubbleState extends State<MessageBubble> {
)!.failed;
break;
case MessageDeliveryStatus.sending:
case MessageDeliveryStatus.sent:
default:
statusColor = Colors.orange;
statusIcon = Icons.schedule;
@@ -2656,6 +2748,7 @@ class _MessageBubbleState extends State<MessageBubble> {
context,
message: message,
isSarMarker: isSarMarker,
routeMetadata: routeMetadata,
),
],
);

View File

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

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/message.dart';
import '../../models/message_route_metadata.dart';
import '../../models/path_selection.dart';
import '../../models/message_reception_details.dart';
import '../../providers/messages_provider.dart';
@@ -209,7 +210,7 @@ Widget buildSentDirectSignalStatus(
_techChip(
context,
icon: Icons.alt_route,
label: hopDisplayLabel(message),
label: hopDisplayLabelForMessage(message, routeMetadata),
color: Colors.indigo,
),
_techChip(
@@ -294,6 +295,17 @@ String hopDisplayLabel(Message message) {
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
}
String hopDisplayLabelForMessage(
Message message,
MessageRouteMetadata? routeMetadata,
) {
final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen;
if (effectivePathLen == 0) return 'Direct';
if (effectivePathLen >= 255 && message.isContactMessage) return 'Direct';
if (effectivePathLen >= 255) return 'Unknown';
return '$effectivePathLen hop${effectivePathLen == 1 ? '' : 's'}';
}
Widget _techChip(
BuildContext context, {
required IconData icon,

View File

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

View File

@@ -3,7 +3,9 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../providers/app_provider.dart';
import '../common/contact_avatar.dart';
class MessagesComposer extends StatelessWidget {
final TextEditingController textController;
@@ -17,6 +19,9 @@ class MessagesComposer extends StatelessWidget {
final double bottomPadding;
final String destinationLabel;
final Widget destinationAvatar;
final List<Contact> mentionSuggestions;
final String mentionQuery;
final ValueChanged<Contact> onMentionSelected;
final VoidCallback onShowComposerActions;
final VoidCallback onShowRecipientSelector;
final Future<void> Function() onStartVoiceRecording;
@@ -36,6 +41,9 @@ class MessagesComposer extends StatelessWidget {
required this.bottomPadding,
required this.destinationLabel,
required this.destinationAvatar,
required this.mentionSuggestions,
required this.mentionQuery,
required this.onMentionSelected,
required this.onShowComposerActions,
required this.onShowRecipientSelector,
required this.onStartVoiceRecording,
@@ -50,6 +58,15 @@ class MessagesComposer extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (mentionSuggestions.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
child: _MentionSuggestionsCard(
suggestions: mentionSuggestions,
query: mentionQuery,
onSelected: onMentionSelected,
),
),
SafeArea(
top: false,
child: Padding(
@@ -72,7 +89,7 @@ class MessagesComposer extends StatelessWidget {
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -95,7 +112,7 @@ class MessagesComposer extends StatelessWidget {
),
],
),
const SizedBox(height: 8),
const SizedBox(height: 6),
ListenableBuilder(
listenable: Listenable.merge([
textController,
@@ -153,6 +170,87 @@ class MessagesComposer extends StatelessWidget {
}
}
class _MentionSuggestionsCard extends StatelessWidget {
final List<Contact> suggestions;
final String query;
final ValueChanged<Contact> onSelected;
const _MentionSuggestionsCard({
required this.suggestions,
required this.query,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final visibleCount = suggestions.length.clamp(1, 4);
final maxHeight = visibleCount * 54.0;
return Material(
elevation: 8,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(18),
child: Container(
constraints: BoxConstraints(maxHeight: maxHeight),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.30),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox(height: 2),
itemBuilder: (context, index) {
final contact = suggestions[index];
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => onSelected(contact),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
child: Row(
children: [
ContactAvatar(contact: contact, radius: 16),
const SizedBox(width: 10),
Expanded(
child: Text(
contact.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
},
),
),
],
),
),
);
}
}
class _ComposerActionButton extends StatelessWidget {
final bool isRecording;
final VoidCallback onPressed;
@@ -165,8 +263,8 @@ class _ComposerActionButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: 42,
height: 42,
width: 40,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
@@ -175,7 +273,7 @@ class _ComposerActionButton extends StatelessWidget {
),
),
child: IconButton(
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 22),
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 20),
tooltip: isRecording ? 'Stop recording' : 'More actions',
onPressed: onPressed,
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
@@ -203,7 +301,7 @@ class _DestinationSelector extends StatelessWidget {
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Ink(
height: 42,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
@@ -212,17 +310,17 @@ class _DestinationSelector extends StatelessWidget {
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 10),
const SizedBox(width: 8),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
@@ -230,7 +328,7 @@ class _DestinationSelector extends StatelessWidget {
),
Icon(
Icons.expand_more_rounded,
size: 20,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
@@ -261,7 +359,7 @@ class _MessageInput extends StatelessWidget {
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132),
constraints: const BoxConstraints(minHeight: 42, maxHeight: 116),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
@@ -284,7 +382,7 @@ class _MessageInput extends StatelessWidget {
: null,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: TextField(
controller: textController,
focusNode: focusNode,
@@ -375,8 +473,8 @@ class _SendButton extends StatelessWidget {
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 46,
height: 46,
width: 42,
height: 42,
decoration: BoxDecoration(
color: canSendText || isRecording
? Theme.of(context).colorScheme.primary
@@ -410,7 +508,7 @@ class _SendButton extends StatelessWidget {
)
: Icon(
isRecording ? Icons.mic_rounded : Icons.send_rounded,
size: 22,
size: 20,
color: canSendText || isRecording
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,

View File

@@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/message.dart';
import '../../providers/messages_provider.dart';
import '../common/bidirectional_refresh.dart';
import '../../widgets/messages/message_bubble.dart';
class MessagesContent extends StatelessWidget {
static const double defaultPadding = 8;
final List<Message> messages;
final List<DisplayMessageEntry> messages;
final ScrollController scrollController;
final String? highlightedMessageId;
final double bottomContentPadding;
@@ -30,14 +32,18 @@ class MessagesContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RefreshIndicator(
const scrollPhysics = BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
);
return BidirectionalRefresh(
onRefresh: onRefresh,
child: messages.isEmpty
? LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
physics: const AlwaysScrollableScrollPhysics(),
physics: scrollPhysics,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
@@ -69,6 +75,7 @@ class MessagesContent extends StatelessWidget {
: ListView.builder(
controller: scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
physics: scrollPhysics,
reverse: true,
padding: EdgeInsets.fromLTRB(
defaultPadding,
@@ -78,11 +85,13 @@ class MessagesContent extends StatelessWidget {
),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final entry = messages[index];
final message = entry.message;
return MessageBubble(
key: ValueKey(message.id),
message: message,
receivedCopies: entry.occurrenceCount,
isHighlighted: message.id == highlightedMessageId,
onNavigateToMap: onNavigateToMap,
onTap: onMessageTap == null

View File

@@ -12,6 +12,7 @@ class RecipientSelectorSheet extends StatefulWidget {
final Map<String, int> unreadCountsByPublicKey;
final String? currentDestinationType;
final String? currentRecipientPublicKey;
final bool showAllOption;
final Function(String type, Contact? recipient) onSelect;
const RecipientSelectorSheet({
@@ -23,6 +24,7 @@ class RecipientSelectorSheet extends StatefulWidget {
required this.unreadCountsByPublicKey,
this.currentDestinationType,
this.currentRecipientPublicKey,
this.showAllOption = true,
required this.onSelect,
});
@@ -61,6 +63,11 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
final filteredContacts = _filterContacts(widget.contacts);
final filteredRooms = _filterContacts(widget.rooms);
final filteredChannels = _filterContacts(widget.channels);
final showChannelsSection = widget.channels.isNotEmpty;
final showContactsSection = widget.contacts.isNotEmpty;
final showRoomsSection = widget.rooms.isNotEmpty;
final showAnyRecipients =
showChannelsSection || showContactsSection || showRoomsSection;
return Container(
constraints: BoxConstraints(
@@ -142,21 +149,23 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
child: ListView(
shrinkWrap: true,
children: [
_buildOptionTile(
context: context,
icon: Icons.all_inbox,
title: l10n.showAll,
subtitle: 'All messages',
unreadCount: widget.unreadCount,
isSelected: _isSelected('all', null),
onTap: () {
widget.onSelect('all', null);
Navigator.pop(context);
},
),
const Divider(),
if (widget.showAllOption) ...[
_buildOptionTile(
context: context,
icon: Icons.all_inbox,
title: l10n.showAll,
subtitle: 'All messages',
unreadCount: widget.unreadCount,
isSelected: _isSelected('all', null),
onTap: () {
widget.onSelect('all', null);
Navigator.pop(context);
},
),
if (showAnyRecipients) const Divider(),
],
// Channels section
if (widget.channels.isNotEmpty) ...[
if (showChannelsSection) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -204,10 +213,12 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
}),
],
const Divider(),
if (showChannelsSection &&
(showContactsSection || showRoomsSection))
const Divider(),
// Contacts section
if (widget.contacts.isNotEmpty) ...[
if (showContactsSection) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -253,10 +264,10 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
}),
],
const Divider(),
if (showContactsSection && showRoomsSection) const Divider(),
// Rooms section
if (widget.rooms.isNotEmpty) ...[
if (showRoomsSection) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
@@ -302,9 +313,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
],
// Empty state
if (widget.contacts.isEmpty &&
widget.rooms.isEmpty &&
widget.channels.isEmpty) ...[
if (!showAnyRecipients) ...[
Padding(
padding: const EdgeInsets.all(32),
child: Column(

View File

@@ -8,6 +8,10 @@ PODS:
- FlutterMacOS
- file_picker (0.0.1):
- FlutterMacOS
- file_selector_macos (0.0.1):
- FlutterMacOS
- flutter_avif_macos (0.0.1):
- FlutterMacOS
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
@@ -19,10 +23,6 @@ PODS:
- FlutterMacOS
- nsd_macos (0.0.1):
- FlutterMacOS
- ObjectBox (4.4.1)
- objectbox_flutter_libs (0.0.1):
- FlutterMacOS
- ObjectBox (= 4.4.1)
- package_info_plus (0.0.1):
- FlutterMacOS
- path_provider_foundation (0.0.1):
@@ -35,6 +35,9 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
@@ -43,23 +46,21 @@ DEPENDENCIES:
- codec2_flutter (from `Flutter/ephemeral/.symlinks/plugins/codec2_flutter/macos`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- flutter_avif_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos`)
- flutter_blue_plus_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin`)
- nsd_macos (from `Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos`)
- objectbox_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/objectbox_flutter_libs/macos`)
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
SPEC REPOS:
trunk:
- ObjectBox
EXTERNAL SOURCES:
audioplayers_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/darwin
@@ -69,6 +70,10 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
flutter_avif_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_avif_macos/macos
flutter_blue_plus_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_blue_plus_darwin/darwin
flutter_local_notifications:
@@ -79,8 +84,6 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/darwin
nsd_macos:
:path: Flutter/ephemeral/.symlinks/plugins/nsd_macos/macos
objectbox_flutter_libs:
:path: Flutter/ephemeral/.symlinks/plugins/objectbox_flutter_libs/macos
package_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos
path_provider_foundation:
@@ -91,28 +94,31 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
sqflite_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
SPEC CHECKSUMS:
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
codec2_flutter: 50d930e6a1a187f05891b89300c6ef589651bfbd
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7
flutter_avif_macos: 9ed61d67adfbd6964eccb59971020fb55c31fc11
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
nsd_macos: a472240e770b92f6c6df1022403aa29c90d012e3
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
objectbox_flutter_libs: f51d18f6a4b5965c218843373b7dc5ed5e3a2008
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
record_macos: 7f227161b93c49e7e34fe681c5891c8622c8cc8b
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
PODFILE CHECKSUM: e3de8f2486e300492cc80a2fe0576e9d269ba22b
COCOAPODS: 1.16.2

View File

@@ -795,8 +795,8 @@ packages:
dependency: "direct main"
description:
path: "."
ref: main
resolved-ref: bd3744ee21376b81be5f852cd0c1a82c0df40460
ref: "813f5b3"
resolved-ref: "813f5b3e0b9d2ea6b85a428be443bf5e6a38c6c5"
url: "https://github.com/dz0ny/meshcore_client.git"
source: git
version: "0.1.0"

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
# 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.
version: 2026.0308.3+23
version: 2026.0313.2+29
environment:
sdk: ^3.9.2
@@ -44,7 +44,7 @@ dependencies:
meshcore_client:
git:
url: https://github.com/dz0ny/meshcore_client.git
ref: main
ref: "813f5b3"
# Codec2 ultra-low-bitrate speech codec (FFI plugin)
codec2_flutter:

View File

@@ -0,0 +1,148 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message.dart';
import 'package:meshcore_sar_app/providers/app_provider.dart';
Uint8List _ownPublicKey() =>
Uint8List.fromList([1, 2, 3, 4, 5, 6, ...List<int>.filled(26, 0)]);
void main() {
group('AppProvider channel info handling', () {
test('treats zeroed unnamed non-public channel as deleted', () {
expect(AppProvider.isDeletedChannelInfo(2, '', Uint8List(16)), isTrue);
});
test('keeps unnamed non-public channel when secret is configured', () {
final secret = Uint8List.fromList([1, ...List<int>.filled(15, 0)]);
expect(AppProvider.isDeletedChannelInfo(2, '', secret), isFalse);
expect(AppProvider.channelContactName(2, ''), 'Channel 2');
});
});
group('AppProvider self replay handling', () {
test('prefers self name over meshcore device name', () {
expect(
AppProvider.preferredSelfDisplayName(
deviceName: 'MeshCore-dz0ny (SI)',
selfName: 'dz0ny (SI)',
),
'dz0ny (SI)',
);
});
test('ignores direct self replay without hops', () {
final message = Message(
id: 'dm-self',
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello',
receivedAt: DateTime.now(),
);
expect(
AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: _ownPublicKey(),
ownName: 'dz0ny (SI)',
),
isTrue,
);
});
test('ignores room self replay without hops', () {
final message = Message(
id: 'room-self',
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
recipientPublicKey: Uint8List.fromList([9, 9, 9, 9, 9, 9]),
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello room',
receivedAt: DateTime.now(),
);
expect(
AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: _ownPublicKey(),
ownName: 'dz0ny (SI)',
),
isTrue,
);
});
test('keeps direct self replay when it traversed hops', () {
final message = Message(
id: 'dm-self-routed',
messageType: MessageType.contact,
senderPublicKeyPrefix: Uint8List.fromList([1, 2, 3, 4, 5, 6]),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello',
receivedAt: DateTime.now(),
);
expect(
AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: _ownPublicKey(),
ownName: 'dz0ny (SI)',
),
isFalse,
);
});
test('ignores channel self replay by self name without hops', () {
final message = Message(
id: 'channel-self',
messageType: MessageType.channel,
senderName: 'dz0ny (SI)',
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello public',
receivedAt: DateTime.now(),
);
expect(
AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: _ownPublicKey(),
ownName: 'dz0ny (SI)',
),
isTrue,
);
});
test('keeps channel self replay when it traversed hops', () {
final message = Message(
id: 'channel-self-routed',
messageType: MessageType.channel,
senderName: 'dz0ny (SI)',
channelIdx: 0,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'hello public',
receivedAt: DateTime.now(),
);
expect(
AppProvider.shouldIgnoreSelfReplay(
message: message,
ownPublicKey: _ownPublicKey(),
ownName: 'dz0ny (SI)',
),
isFalse,
);
});
});
}

View File

@@ -0,0 +1,86 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
void main() {
group('ConnectionProvider channel slot occupancy', () {
test('treats non-zero secret as configured', () {
expect(
ConnectionProvider.channelHasConfiguredSecret(Uint8List(16)),
isFalse,
);
expect(
ConnectionProvider.channelHasConfiguredSecret(
Uint8List.fromList([1, ...List<int>.filled(15, 0)]),
),
isTrue,
);
});
test('treats duplicate hashtag channels as conflicts', () {
expect(
ConnectionProvider.isDuplicateChannelName(
requestedName: '#sar',
existingName: '#sar',
),
isTrue,
);
});
test('allows private channels with the same name to coexist', () {
expect(
ConnectionProvider.isDuplicateChannelName(
requestedName: 'Ops',
existingName: 'Ops',
),
isFalse,
);
});
test('does not treat arbitrary empty channel info as deletion', () {
final provider = ConnectionProvider();
expect(
provider.shouldTreatChannelInfoAsDeleted(3, '', Uint8List(16)),
isFalse,
);
});
test('finds first missing custom channel slot', () {
expect(
ConnectionProvider.firstAvailableChannelSlot(
occupiedIndices: {1, 2, 4},
maxChannels: 6,
),
3,
);
});
test('returns null when all custom channel slots are occupied', () {
expect(
ConnectionProvider.firstAvailableChannelSlot(
occupiedIndices: {1, 2, 3, 4},
maxChannels: 5,
),
isNull,
);
});
test('private channels with same name do not block slot selection', () {
expect(
ConnectionProvider.isDuplicateChannelName(
requestedName: 'RPT_admins',
existingName: 'RPT_admins',
),
isFalse,
);
expect(
ConnectionProvider.firstAvailableChannelSlot(
occupiedIndices: {1, 2, 3},
maxChannels: 6,
),
4,
);
});
});
}

View File

@@ -1,8 +1,10 @@
import 'dart:typed_data';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/models/contact_group.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
@@ -426,6 +428,46 @@ void main() {
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
},
);
test('prefers a local name override and preserves it across refreshes', () {
provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One');
final renamed = provider.findContactByKey(publicKey)!;
expect(renamed.nameOverride, 'Rescue One');
expect(renamed.displayName, 'Rescue One');
provider.addOrUpdateContact(
Contact(
publicKey: publicKey,
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Updated Advertised Name',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (46.0569 * 1e6).toInt(),
advLon: (14.5058 * 1e6).toInt(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final refreshed = provider.findContactByKey(publicKey)!;
expect(refreshed.nameOverride, 'Rescue One');
expect(refreshed.displayName, 'Rescue One');
expect(refreshed.advName, 'Updated Advertised Name');
});
test('persists a local name override across reloads', () async {
provider.setContactNameOverride(publicKeyHex(publicKey), 'Rescue One');
await Future<void>.delayed(Duration.zero);
final reloadedProvider = ContactsProvider();
await reloadedProvider.initializeEarly();
final reloaded = reloadedProvider.findContactByKey(publicKey)!;
expect(reloaded.nameOverride, 'Rescue One');
expect(reloaded.displayName, 'Rescue One');
});
});
group('ContactsProvider route updates', () {
@@ -457,6 +499,24 @@ void main() {
expect(updated.routeCanonicalText, 'AABB,CCDD');
});
test('stores inferred fallback gps when route is set locally', () {
final route = ContactRouteCodec.parse('AABB,CCDD');
const fallback = LatLng(46.1001, 14.5002);
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
inferredFallbackLocation: fallback,
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(46.1001, 0.000001));
expect(updated.displayLocation!.longitude, closeTo(14.5002, 0.000001));
expect(updated.advertHistory, isNotEmpty);
});
test('resetContactRouteLocal clears route state', () {
final route = ContactRouteCodec.parse('AA,BB,CC');
provider.setContactRouteLocal(
@@ -471,6 +531,153 @@ void main() {
expect(updated.routeHasPath, isFalse);
expect(updated.routeSummary, 'Flood/Unknown');
});
test('retains existing route when contact refresh omits path', () {
final retainedRoute = ContactRouteCodec.parse('AABB,CCDD');
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
paddedPathBytes: retainedRoute.paddedPathBytes,
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
name: 'Routey',
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.routeHasPath, isTrue);
expect(updated.routeCanonicalText, 'AABB,CCDD');
});
test('applies retained pending advert route when contact is resolved', () {
final pendingKey = createPublicKey(96);
final retainedRoute = ContactRouteCodec.parse('1122,3344');
provider.retainReceivedRoute(
pendingKey,
signedEncodedPathLen: retainedRoute.signedEncodedPathLen,
paddedPathBytes: retainedRoute.paddedPathBytes,
);
provider.addPendingAdvert(pendingKey);
provider.addOrUpdateContact(
createContact(
key: pendingKey,
type: ContactType.chat,
name: 'Pending Routey',
).copyWith(outPathLen: -1, outPath: Uint8List(0)),
);
final updated = provider.findContactByKey(pendingKey)!;
expect(updated.routeHasPath, isTrue);
expect(updated.routeCanonicalText, '1122,3344');
expect(
provider.pendingAdverts.where(
(advert) => advert.publicKeyHex == updated.publicKeyHex,
),
isEmpty,
);
});
test('infers a fallback location 100m from last-hop repeater', () {
final repeaterKey = Uint8List.fromList([
0xCC,
0xDD,
0x10,
0x11,
0x12,
0x13,
...List<int>.generate(26, (index) => index + 20),
]);
provider.addOrUpdateContact(
createContact(
key: repeaterKey,
type: ContactType.repeater,
name: 'Relay Alpha',
),
);
final targetKey = createPublicKey(120);
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.addOrUpdateContact(
Contact(
publicKey: targetKey,
type: ContactType.chat,
flags: 0,
outPathLen: route.signedEncodedPathLen,
outPath: route.paddedPathBytes,
advName: 'No GPS',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(targetKey)!;
final inferred = updated.displayLocation;
final repeater = provider.findContactByKey(repeaterKey)!;
final repeaterLocation = repeater.displayLocation;
expect(inferred, isNotNull);
expect(repeaterLocation, isNotNull);
final distanceMeters = Geolocator.distanceBetween(
repeaterLocation!.latitude,
repeaterLocation.longitude,
inferred!.latitude,
inferred.longitude,
);
expect(distanceMeters, closeTo(100.0, 8.0));
});
test(
'does not infer a fallback location when the contact advertises one',
() {
final repeaterKey = Uint8List.fromList([
0xCC,
0xDD,
0x10,
0x11,
0x12,
0x13,
...List<int>.generate(26, (index) => index + 20),
]);
provider.addOrUpdateContact(
createContact(
key: repeaterKey,
type: ContactType.repeater,
name: 'Relay Alpha',
),
);
final targetKey = createPublicKey(121);
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.addOrUpdateContact(
Contact(
publicKey: targetKey,
type: ContactType.chat,
flags: 0,
outPathLen: route.signedEncodedPathLen,
outPath: route.paddedPathBytes,
advName: 'Has Advert',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: (45.1234 * 1e6).toInt(),
advLon: (13.8765 * 1e6).toInt(),
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(targetKey)!;
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.000001));
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.000001));
},
);
});
group('ContactsProvider.updateFastGps', () {
@@ -533,4 +740,79 @@ void main() {
expect(after.advLon, equals(before.advLon));
});
});
group('ContactsProvider saved contact groups', () {
late ContactsProvider provider;
setUp(() {
SharedPreferences.setMockInitialValues({});
provider = ContactsProvider();
});
test('adds and removes saved groups by filter', () async {
expect(provider.savedContactGroups, isEmpty);
await provider.addSavedGroupForFilter('teamMembers', 'alpha');
expect(provider.savedContactGroups, hasLength(1));
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isTrue);
expect(provider.hasSavedGroupForFilter('teamMembers', 'ALPHA'), isTrue);
await provider.removeSavedGroupForFilter('teamMembers', 'ALPHA');
expect(provider.savedContactGroups, isEmpty);
expect(provider.hasSavedGroupForFilter('teamMembers', 'alpha'), isFalse);
});
test('loads persisted saved groups during initialization', () async {
await provider.addSavedGroupForFilter('rooms', 'ops');
final restored = ContactsProvider();
await restored.initializeEarly();
expect(restored.savedGroupsForSection('rooms'), hasLength(1));
expect(restored.savedGroupsForSection('rooms').first.query, 'ops');
expect(restored.savedGroupsForSection('rooms').first.label, 'ops');
});
test('replaces persisted auto groups for a section', () async {
await provider.replaceAutoGroupsForSection('repeaters', [
SavedContactGroup(
id: '${ContactsProvider.autoGroupIdPrefix}repeaters_al',
sectionKey: 'repeaters',
label: 'AL-',
query: 'AL-',
createdAt: DateTime(2026, 3, 10, 12),
matchPrefixes: const ['AL-'],
isAutoGroup: true,
),
SavedContactGroup(
id: '${ContactsProvider.autoGroupIdPrefix}repeaters_others',
sectionKey: 'repeaters',
label: 'Others',
query: 'Others',
createdAt: DateTime(2026, 3, 10, 12),
matchPrefixes: const ['CR-', 'DE-'],
isAutoGroup: true,
),
]);
final restored = ContactsProvider();
await restored.initializeEarly();
expect(restored.savedGroupsForSection('repeaters'), hasLength(2));
expect(
restored.savedGroupsForSection('repeaters').first.matchPrefixes,
isNotEmpty,
);
expect(
restored.savedGroupsForSection('repeaters').first.isAutoGroup,
isTrue,
);
});
});
}
String publicKeyHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}

View File

@@ -4,6 +4,7 @@ import 'package:fake_async/fake_async.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_reception_details.dart';
import 'package:meshcore_sar_app/models/path_selection.dart';
import 'package:meshcore_sar_app/providers/helpers/message_retry_manager.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
@@ -39,6 +40,46 @@ Message _buildDirectMessage(String id) {
);
}
Message _buildSentChannelMessage({
required String id,
required int senderTimestamp,
String text = 'broadcast',
int channelIdx = 0,
}) {
return Message(
id: id,
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
channelIdx: channelIdx,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: senderTimestamp,
text: text,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
);
}
Message _buildReceivedChannelReplay({
required String id,
required int senderTimestamp,
String text = 'broadcast',
int channelIdx = 0,
String senderName = 'Radio Alpha',
}) {
return Message(
id: id,
messageType: MessageType.channel,
channelIdx: channelIdx,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: senderTimestamp,
text: text,
senderName: senderName,
receivedAt: DateTime.now(),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -123,19 +164,9 @@ void main() {
test('channel messages are marked sent immediately', () {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage(
Message(
id: 'c1',
messageType: MessageType.channel,
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000000,
text: 'broadcast',
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
),
_buildSentChannelMessage(id: 'c1', senderTimestamp: 1700000000),
);
provider.markMessageSent('c1', 0, 0);
@@ -146,6 +177,259 @@ void main() {
);
});
test('channel warning appears when no echo arrives in time', () {
fakeAsync((async) {
final provider = MessagesProvider();
provider.addSentMessage(
_buildSentChannelMessage(id: 'c-warn', senderTimestamp: 1700000000),
);
provider.markMessageSent('c-warn', 0, 0);
expect(provider.hasChannelSendWarning('c-warn'), isFalse);
async.elapse(const Duration(seconds: 12));
async.flushMicrotasks();
expect(provider.hasChannelSendWarning('c-warn'), isTrue);
});
});
test('channel warning clears when echo arrives before timeout', () {
fakeAsync((async) {
final provider = MessagesProvider();
provider.addSentMessage(
_buildSentChannelMessage(
id: 'c-warn-echo',
senderTimestamp: 1700000000,
),
);
provider.markMessageSent('c-warn-echo', 0, 0);
async.elapse(const Duration(seconds: 6));
provider.handleMessageEcho('c-warn-echo', 1, 4, -90);
async.elapse(const Duration(seconds: 6));
async.flushMicrotasks();
expect(provider.hasChannelSendWarning('c-warn-echo'), isFalse);
});
});
test('channel warning clears when replay is deduped into sent bubble', () {
fakeAsync((async) {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage(
_buildSentChannelMessage(
id: 'c-warn-replay',
senderTimestamp: 1700000100,
),
);
provider.markMessageSent('c-warn-replay', 0, 0);
async.elapse(const Duration(seconds: 12));
async.flushMicrotasks();
expect(provider.hasChannelSendWarning('c-warn-replay'), isTrue);
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-warn-replay-incoming',
senderTimestamp: 1700000101,
senderName: 'dz0ny (SI)',
),
);
expect(provider.hasChannelSendWarning('c-warn-replay'), isFalse);
expect(provider.messages, hasLength(1));
});
});
test('channel replay is deduped for self sender within repeat window', () {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage(
_buildSentChannelMessage(id: 'c-echo', senderTimestamp: 1700000100),
);
provider.markMessageSent('c-echo', 0, 0);
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-echo-incoming',
senderTimestamp: 1700000104,
senderName: 'dz0ny (SI)',
),
);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('c-echo'));
expect(provider.messages.single.senderName, equals('dz0ny (SI)'));
});
test(
'channel replay is not deduped for different sender with same text',
() {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage(
_buildSentChannelMessage(
id: 'c-no-echo',
senderTimestamp: 1700000200,
),
);
provider.markMessageSent('c-no-echo', 0, 0);
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-no-echo-incoming',
senderTimestamp: 1700000201,
senderName: 'Radio Alpha',
),
);
expect(provider.messages, hasLength(2));
},
);
test('channel replay is not deduped outside repeat window', () {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addSentMessage(
_buildSentChannelMessage(id: 'c-late', senderTimestamp: 1700000300),
);
provider.markMessageSent('c-late', 0, 0);
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-late-incoming',
senderTimestamp: 1700000331,
senderName: 'dz0ny (SI)',
),
);
expect(provider.messages, hasLength(2));
});
test('channel replay can dedupe using lazily resolved self name', () {
final provider = MessagesProvider();
provider.addSentMessage(
_buildSentChannelMessage(id: 'c-lazy', senderTimestamp: 1700000400),
);
provider.markMessageSent('c-lazy', 0, 0);
provider.resolveContactNameCallback = (_) => 'dz0ny (SI)';
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-lazy-incoming',
senderTimestamp: 1700000406,
senderName: 'dz0ny (SI)',
),
);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('c-lazy'));
});
test('channel replay dedupes meshcore-prefixed self sender name', () {
final provider = MessagesProvider();
provider.resolveContactNameCallback = (_) => 'MeshCore-dz0ny (SI)';
provider.addSentMessage(
_buildSentChannelMessage(id: 'c-prefix', senderTimestamp: 1700000500),
);
provider.markMessageSent('c-prefix', 0, 0);
provider.addMessage(
_buildReceivedChannelReplay(
id: 'c-prefix-incoming',
senderTimestamp: 1700000500,
senderName: 'dz0ny (SI)',
),
);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('c-prefix'));
});
test('duplicate incoming message increments received copy count', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([7, 7, 4, 1, 7, 2]);
provider.addMessage(
Message(
id: 'copy-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000600,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
receptionDetailsSnapshot: MessageReceptionDetails(
capturedAt: DateTime.now(),
),
);
provider.addMessage(
Message(
id: 'copy-2',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000601,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
receptionDetailsSnapshot: MessageReceptionDetails(
capturedAt: DateTime.now(),
),
);
expect(provider.messages, hasLength(1));
expect(
provider.getMessageReceptionDetails('copy-1')?.receivedCopies,
equals(2),
);
});
test('display list collapses stored duplicates and sums copy counts', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([5, 4, 3, 2, 1, 0]);
provider.addMessages([
Message(
id: 'dup-a',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700000705,
text: 'same payload',
senderName: 'Klemen',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
Message(
id: 'dup-b',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 2,
textType: MessageTextType.plain,
senderTimestamp: 1700000700,
text: 'same payload',
senderName: 'Klemen',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
]);
final display = provider.buildDisplayMessages(
provider.getRecentMessages(),
);
expect(display, hasLength(1));
expect(display.single.message.id, equals('dup-a'));
expect(display.single.occurrenceCount, equals(2));
});
test('missing ACK schedules a delayed retransmission', () {
fakeAsync((async) {
final provider = MessagesProvider();

View File

@@ -12,7 +12,7 @@ import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
Contact _buildContact({
Contact buildContact({
required List<int> prefix,
required ContactType type,
String name = 'Test Contact',
@@ -131,6 +131,108 @@ void main() {
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
});
test('dedupes repeated incoming contact messages with same text', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
provider.addMessage(
Message(
id: 'dup-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700001000,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
provider.addMessage(
Message(
id: 'dup-2',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700001999,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
expect(provider.messages, hasLength(1));
expect(provider.messages.single.id, equals('dup-1'));
});
test('keeps separate incoming messages when text differs', () {
final provider = MessagesProvider();
final sender = Uint8List.fromList([0, 1, 2, 3, 4, 5]);
provider.addMessage(
Message(
id: 'unique-1',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700002000,
text: 'same payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
provider.addMessage(
Message(
id: 'unique-2',
messageType: MessageType.contact,
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: 1700002999,
text: 'different payload',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: sender,
),
);
expect(provider.messages, hasLength(2));
});
test('removed SAR markers stay hidden after provider restore', () async {
final provider = MessagesProvider();
final message = Message(
id: 'sar-hidden',
messageType: MessageType.channel,
channelIdx: 0,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: 1700000003,
text: 'S:🧑:0:46.0569,14.5058:Hidden marker',
receivedAt: DateTime.now(),
senderPublicKeyPrefix: Uint8List.fromList([0, 1, 2, 3, 4, 5]),
);
provider.addMessage(message);
expect(
provider.sarMarkers.map((marker) => marker.id),
contains(message.id),
);
await provider.removeSarMarker(message.id);
expect(provider.sarMarkers, isEmpty);
final restoredProvider = MessagesProvider();
await restoredProvider.initialize();
expect(
restoredProvider.messages.map((stored) => stored.id),
contains(message.id),
);
expect(
restoredProvider.sarMarkers.map((marker) => marker.id),
isNot(contains(message.id)),
);
expect(restoredProvider.removedSarMarkerIds, contains(message.id));
});
test('tracks and persists media transfer counts and downloaders', () async {
final provider = MessagesProvider();
final voiceEnvelope = VoiceEnvelope(
@@ -218,12 +320,12 @@ void main() {
test('marks only the selected contact destination as read', () {
final provider = MessagesProvider();
final alice = _buildContact(
final alice = buildContact(
prefix: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15],
type: ContactType.chat,
name: 'Alice',
);
final bob = _buildContact(
final bob = buildContact(
prefix: [0x20, 0x21, 0x22, 0x23, 0x24, 0x25],
type: ContactType.chat,
name: 'Bob',

View File

@@ -0,0 +1,195 @@
import 'dart:typed_data';
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/models/contact.dart';
import 'package:meshcore_sar_app/models/contact_group.dart';
import 'package:meshcore_sar_app/providers/connection_provider.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/providers/map_provider.dart';
import 'package:meshcore_sar_app/providers/messages_provider.dart';
import 'package:meshcore_sar_app/screens/contacts_tab.dart';
import 'package:meshcore_sar_app/utils/contact_grouping.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Contact buildChannel({required String name, required int channelIndex}) {
final publicKey = Uint8List(32);
publicKey[0] = 0xFF;
publicKey[1] = channelIndex;
return Contact(
publicKey: publicKey,
type: ContactType.channel,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Contact buildRepeater({required int seed, required String name}) {
final publicKey = Uint8List(32);
publicKey[0] = seed;
publicKey[1] = seed + 1;
return Contact(
publicKey: publicKey,
type: ContactType.repeater,
flags: 0,
outPathLen: -1,
outPath: Uint8List(0),
advName: name,
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 46056000 + seed,
advLon: 14505000 + seed,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
}
Future<void> pumpContactsTab(
WidgetTester tester, {
List<Contact> contacts = const [],
List<SavedContactGroup> savedGroups = const [],
}) async {
final contactsProvider = ContactsProvider();
for (final contact in contacts) {
contactsProvider.addOrUpdateContact(contact);
}
if (savedGroups.isNotEmpty) {
await contactsProvider.replaceAutoGroupsForSection(
'repeaters',
savedGroups,
);
}
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: contactsProvider),
ChangeNotifierProvider(create: (_) => ConnectionProvider()),
ChangeNotifierProvider(create: (_) => MessagesProvider()),
ChangeNotifierProvider(create: (_) => MapProvider()),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: ContactsTab()),
),
),
);
await tester.pumpAndSettle();
}
testWidgets('private channel activity card shows delete action in sheet', (
tester,
) async {
await pumpContactsTab(
tester,
contacts: [buildChannel(name: 'Ops', channelIndex: 3)],
);
expect(find.text('Ops'), findsOneWidget);
await tester.tap(find.text('Ops'));
await tester.pumpAndSettle();
expect(find.text('Delete Channel'), findsOneWidget);
await tester.tap(find.text('Delete Channel'));
await tester.pumpAndSettle();
expect(find.text('Delete Channel'), findsWidgets);
expect(
find.text(
'Are you sure you want to delete channel "Ops"? This action cannot be undone.',
),
findsOneWidget,
);
});
testWidgets('repeaters show Others group when multiple groups exist', (
tester,
) async {
final repeaters = [
buildRepeater(seed: 10, name: 'AL-1'),
buildRepeater(seed: 11, name: 'AL-2'),
buildRepeater(seed: 12, name: 'AL-3'),
buildRepeater(seed: 13, name: 'AL-4'),
buildRepeater(seed: 20, name: 'BR-1'),
buildRepeater(seed: 21, name: 'BR-2'),
buildRepeater(seed: 22, name: 'BR-3'),
buildRepeater(seed: 23, name: 'BR-4'),
buildRepeater(seed: 30, name: 'Lone Relay'),
];
final inferredGroups = ContactGrouping.inferGroups(repeaters);
final savedGroups = inferredGroups
.map(
(group) => SavedContactGroup(
id: 'repeaters_${group.key}',
sectionKey: 'repeaters',
label: group.label,
query: group.label,
createdAt: DateTime(2026, 3, 13, 10),
matchPrefixes: group.matchPrefixes,
isAutoGroup: true,
),
)
.toList();
await pumpContactsTab(
tester,
contacts: repeaters,
savedGroups: savedGroups,
);
expect(find.text('AL-'), findsOneWidget);
expect(find.text('BR-'), findsOneWidget);
expect(find.text('Others'), findsOneWidget);
expect(find.text('Lone Relay'), findsNothing);
});
testWidgets('repeaters stay flat when only one group exists', (tester) async {
final repeaters = [
buildRepeater(seed: 40, name: 'AL-1'),
buildRepeater(seed: 41, name: 'AL-2'),
buildRepeater(seed: 42, name: 'AL-3'),
buildRepeater(seed: 43, name: 'AL-4'),
buildRepeater(seed: 50, name: 'Lone Relay'),
];
final inferredGroups = ContactGrouping.inferGroups(repeaters);
final savedGroups = inferredGroups
.map(
(group) => SavedContactGroup(
id: 'repeaters_${group.key}',
sectionKey: 'repeaters',
label: group.label,
query: group.label,
createdAt: DateTime(2026, 3, 13, 10),
matchPrefixes: group.matchPrefixes,
isAutoGroup: true,
),
)
.toList();
await pumpContactsTab(
tester,
contacts: repeaters,
savedGroups: savedGroups,
);
expect(find.text('AL-'), findsOneWidget);
expect(find.text('Others'), findsNothing);
expect(find.text('Lone Relay'), findsOneWidget);
});
}

View File

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

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