Compare commits

...

75 Commits

Author SHA1 Message Date
Janez T
f286b0afd1 fix: Show signal chips on double tap 2026-03-18 16:59:02 +01:00
Janez T
68ec72534d fix: Sensor tiles — move channel label to bottom-right, smaller
Channel chip (ch2) was inline with the label, stealing horizontal
space and causing label truncation ('Temper...'). Now positioned as
a tiny 9px label anchored bottom-right of the tile, giving the full
width to the metric label and value.
2026-03-18 16:58:48 +01:00
Janez T
6af8b62d9b fix: Show message signal chips by default #123 2026-03-18 16:38:19 +01:00
Janez T
01fbb7de6c fix: Profile duplication on device connect
DeviceKey resolver was falling back to deviceId when publicKey wasn't
yet available during early connection. This created two different keys
(id:xxx then pk:yyy) for the same device, causing a new profile to be
created each time.

Now returns null until publicKey is available, so profile sync waits
for the stable identifier.
2026-03-18 14:45:55 +01:00
Janez T
9303d24b69 fix: Remove duplicate sensor cards for temperature, humidity, pressure
Core telemetry fields (temperature, humidity, pressure, voltage) were
shown twice: once from the ContactTelemetry top-level fields and again
from extraSensorData channel-keyed entries (e.g. temperature_2).

Now skips extraSensorData keys that are already represented by core
telemetry cards.
2026-03-18 14:37:06 +01:00
Janez T
140088fe2f fix: Improve message signal chip behavior 2026-03-18 14:29:33 +01:00
Janez T
67d8e1c906 fix: Deduplicate telemetry from 0x8B + 0x8C double delivery
When firmware sends telemetry via both pushTelemetryResponse (0x8B)
and pushBinaryResponse (0x8C), the same LPP data was parsed and
applied twice, causing duplicate sensor entries.

Added hash-based dedup: same payload for same contact within 2s is
skipped. Also added minimum length check for binary responses.
2026-03-18 14:24:38 +01:00
Janez T
29b75a3155 feat: Save profile defaults per device 2026-03-18 13:34:50 +01:00
Janez T
87cb890877 chore: Linter fixes for USB serial transport 2026-03-17 14:43:47 +01:00
Janez T
095458ece4 feat: USB serial companion support (Android)
- Add MeshCoreSerialService to meshcore_client (0x3C/0x3E framing)
- Add usb_serial package for Android USB OTG
- Add UsbSerialTransport wrapper for Android
- Add USB tab to connection dialog (scan, select, connect)
- Add ConnectionMode.usb + connectSerial/disconnectSerial
- Web Serial stub (not yet implemented)
- Fix message limits: DM 156→150, Channel 127→160 (firmware MAX_TEXT_LEN=160)
2026-03-17 13:46:50 +01:00
Janez T
5659242672 fix: Remove stale analyze warnings #123
ref:
2026-03-17 11:26:59 +01:00
Janez T
3da4ba8fb2 fix: Remove local client override #123 2026-03-17 11:24:01 +01:00
Janez T
89dc1f2862 chore: Bump version to 2026.0317.1+33 2026-03-17 11:22:23 +01:00
Janez T
251932bb47 fix: Use first hop of received path (near sender), not outbound path
Received message path: Sender → hop1 (near sender) → ... → Us
Outbound path: Us → hop1 (near us) → ... → Contact

Was using outbound path which placed contacts near OUR repeater.
Now uses first hop of the received message path — the repeater
nearest to the sender. Only triggers on received messages, not sends.
2026-03-17 11:19:37 +01:00
Janez T
b48139964a feat: RSSI trilateration from multiple repeaters
When a contact has no GPS, estimate their position from RSSI
observations through multiple repeaters:

- 1 repeater: offset by RSSI distance in deterministic direction
- 2 repeaters: weighted midpoint between circle intersections
- 3+ repeaters: weighted centroid with inverse-square weighting
  (closer repeater observations dominate)

Observations stored per-repeater (latest wins), max 8 per contact,
expire after 30 minutes. Each incoming message updates the estimate.
2026-03-17 11:15:51 +01:00
Janez T
d29b85feb8 feat: Estimate contact location from RSSI + last-hop repeater
For contacts without GPS, estimate their position by:
1. Finding the first-hop repeater from their routing path
2. Estimating distance from RSSI using log-distance path loss model
3. Placing them at a deterministic offset from the repeater

Uses LoRa outdoor path loss exponent (n=3.0) with reference RSSI
-30dBm at 1m. Distance clamped to 10m-50km range. Bearing derived
from contact public key hash for stable positioning.
2026-03-17 11:08:56 +01:00
Janez T
c01ef7a059 fix: DM retry stuck — reduce jitter, fix flood-only retry count
- maxRetryAttemptsFloodOnly: 1→0 (was causing an extra retry for
  flood contacts that the official app doesn't do)
- isLastAttempt: fix off-by-one (retryAttempt already incremented
  when called from _scheduleRetry)
- Reduce ACK timeout jitter from 1-8s to 0.5-2s to avoid long waits
  that make the UI appear stuck
2026-03-17 10:48:53 +01:00
Janez T
fa2b586ab7 i18n: Add 247 missing translation keys across all 13 languages
Replace hardcoded English strings in 32 Dart source files with l10n
references. Covers settings labels, UI actions, map/SAR features,
device config, notifications, profiles, spectrum scan, and status
messages for sl, de, hr, pl, es, fr, it, el, ru, tr, uk, zh, pt.
2026-03-17 10:36:58 +01:00
Janez T
bbd5d108cb fix: Show neighbours in bottom sheet instead of dialog 2026-03-17 10:20:28 +01:00
Janez T
e17f24a6bc feat: View Neighbours option for repeater contacts
Sends 'neighbors' text command to the repeater and displays the
response as a list with resolved names and SNR values.
2026-03-17 10:16:11 +01:00
Janez T
372224a62c feat: Share contact as meshcore:// URL via clipboard 2026-03-17 10:07:02 +01:00
Janez T
e4c8709ee2 chore: Point meshcore_client to main branch 2026-03-17 09:54:16 +01:00
Janez T
3c469b62d0 chore: Update meshcore_client ref after rebase 2026-03-17 09:53:14 +01:00
Janez T
1e97ba9831 feat: Favourites support using firmware flags bit 0
- Contact.isFavourite reads bit 0 of firmware flags field
- Toggle favourite via contact action sheet (writes to device)
- Favourites section shown first in contacts tab
- Path size options corrected to [1, 2, 3] bytes
2026-03-17 09:44:38 +01:00
Janez T
6187067017 fix: Pass selfPoint to _routeCandidates after signature change 2026-03-17 09:44:38 +01:00
Janez T
cd165d2372 feat: Add GPS toggle and node sorting 2026-03-17 09:44:38 +01:00
Janez T
59f6df4260 feat: GPS module toggle in device settings 2026-03-17 09:44:38 +01:00
Janez T
464fc5e715 feat: Per-contact path size selector in route editor 2026-03-17 09:44:38 +01:00
Janez T
a76ee4f8f6 fix: Profile coordinator null safety and data loss 2026-03-17 09:44:38 +01:00
Janez T
c884f81ca5 fix: Channel message dedup uses senderTimestamp as primary key 2026-03-16 22:17:52 +01:00
Janez T
993563f059 fix: Prevent invalid map bounds #123 2026-03-16 22:16:36 +01:00
Janez T
cfd233d38e fix: Remove relay labels, add signal bars #123 2026-03-16 22:01:41 +01:00
Janez T
9b14ff4e1a Remove relay info from contact card 2026-03-16 21:54:53 +01:00
Janez T
84cd8eec00 Refine contact refresh flow 2026-03-16 21:52:46 +01:00
Janez T
434d3df53e Fix BottomSheet ancestor lookup 2026-03-16 21:46:37 +01:00
Janez T
1fe260b95e feat: Match official app's advert import and discovery flow
- pushAdvert (0x80): import advert into firmware via cmdGetAdvertPath
  before fetching contact, so name/type/location are available
- pushNewAdvert (0x8A): fire-and-forget importReceivedAdvert to store
  contact in firmware (data already in push payload)
- controlData (0x8E) discovery: no firmware import (matches official app),
  just lookup existing contacts and show generic labels for unknown nodes
- Update meshcore_client git ref to bcbeff0 (burst sync + advert import)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:19:40 +01:00
Janez T
d3d53995a6 Add wakelock plus initialization 2026-03-16 20:38:26 +01:00
Janez T
efbddc8831 Add wakelock plus package 2026-03-16 20:37:04 +01:00
Janez T
0e3acfa4ca Ensure new profile starts empty 2026-03-16 14:04:07 +01:00
Janez T
450b8382e8 Add custom cave map mode 2026-03-16 11:30:53 +01:00
Janez T
420690d683 Enable profiles switch UI 2026-03-16 11:25:51 +01:00
Janez T
fedd7b9a2b Add custom cave map mode 2026-03-16 10:50:44 +01:00
Janez T
c82e0c3452 Update iOS project version to 118 2026-03-16 10:02:43 +01:00
Janez T
a55cfffa59 Redesign contact import screen 2026-03-15 21:02:17 +01:00
Janez T
2bb5b353bd Fix channel refresh telemetry how 2026-03-15 21:00:02 +01:00
Janez T
991421d097 Fix telemetry merge bug 2026-03-15 20:58:12 +01:00
Janez Troha
1dcd85f178 Merge pull request #26 from dz0ny/feat/sensor-telemetry-preview
Feat/sensor telemetry preview
2026-03-15 20:14:31 +01:00
Janez T
5d2f61749e Update sensor parsing layout 2026-03-15 20:14:01 +01:00
Janez T
69377d90a4 Update auto-refresh and UI 2026-03-15 11:15:19 +01:00
Janez T
3e559a4f4e Enforce theme colors on switches 2026-03-15 10:56:47 +01:00
Janez T
a124e3d9f2 Improve sensor settings UI 2026-03-15 10:48:48 +01:00
Janez T
3bf4971279 Update sensor preview interactions 2026-03-15 10:43:37 +01:00
Janez T
e355f28ca9 Update sensor customization view 2026-03-15 10:40:16 +01:00
Janez Troha
7a9be3eb76 Merge pull request #25 from dz0ny/feat/sensor-telemetry-preview
Feat/sensor telemetry preview
2026-03-15 10:34:36 +01:00
Janez T
650f3c66aa Show values in sensor fields 2026-03-15 10:22:53 +01:00
Janez T
df993cd176 Enhance sensor telemetry cards 2026-03-15 10:10:22 +01:00
Janez T
feb3145854 Add sensor contact preview sheet 2026-03-15 09:36:31 +01:00
Janez T
da40acf950 chore: Update Meshcore client pin
ref:
2026-03-15 09:31:18 +01:00
Janez T
66eb32bf44 Fix BLE contact add failure 2026-03-15 09:24:41 +01:00
Janez T
c2e0fa952e Fix contact add failure 2026-03-15 09:19:59 +01:00
Janez T
4557fb4b9e feat: Add Sensor telemetry alerts
ref:
2026-03-15 09:13:32 +01:00
Janez T
c05de2cf0a Update iOS project version 2026-03-14 21:42:05 +01:00
Janez Troha
c5145c2fca Merge pull request #24 from dz0ny/feat/update-changelog-highlights
Add weekly changelog highlights
2026-03-14 21:36:10 +01:00
Janez T
e502bc23b7 Update changelog to drop LPCNet 2026-03-14 21:35:57 +01:00
Janez T
62bf33c9d1 fix: Replace web unsafe hash
ref:
2026-03-14 21:34:57 +01:00
Janez T
7be1460ef6 Update changelog highlights 2026-03-14 21:32:41 +01:00
Janez T
30c273c656 Fix connection provider auto-add bug 2026-03-14 21:30:23 +01:00
Janez T
5712feba53 Reduce composer mention padding 2026-03-14 21:10:22 +01:00
Janez T
6de12225fc feat: Finish discovery flow
ref:
2026-03-14 21:04:23 +01:00
Janez T
6e9e9e397d Fix RX advert data parsing 2026-03-14 16:34:31 +01:00
Janez T
a3147e9d7c Summarize repo changes 2026-03-14 10:39:27 +01:00
Janez T
5e1912d9f2 Remove artificial UI delays 2026-03-14 10:24:50 +01:00
Janez T
b42328bd82 Ensure contact sync waits 2026-03-14 10:24:14 +01:00
Janez T
1b801a10ef Remove hex logs and keep path data 2026-03-14 10:07:11 +01:00
Janez T
06632ee32d Update log display handling 2026-03-14 10:01:17 +01:00
166 changed files with 37436 additions and 6481 deletions

1
.gitignore vendored
View File

@@ -28,6 +28,7 @@ migrate_working_dir/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
pubspec_overrides.yaml
.pub-cache/
.pub/
/build/

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

@@ -84,6 +84,8 @@ PODS:
- Flutter
- vibration (3.0.0):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
@@ -108,6 +110,7 @@ DEPENDENCIES:
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- vibration (from `.symlinks/plugins/vibration/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
@@ -161,6 +164,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"
vibration:
:path: ".symlinks/plugins/vibration/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
@@ -189,6 +194,7 @@ SPEC CHECKSUMS:
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878

View File

@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 115;
CURRENT_PROJECT_VERSION = 119;
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 = 115;
CURRENT_PROJECT_VERSION = 119;
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 = 115;
CURRENT_PROJECT_VERSION = 119;
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 = 115;
CURRENT_PROJECT_VERSION = 119;
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 = 115;
CURRENT_PROJECT_VERSION = 119;
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 = 115;
CURRENT_PROJECT_VERSION = 119;
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>115</string>
<string>119</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,24 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.001473">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00103">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.569758">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.372199">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="95.271262">
<testcase classname="fastlane.lanes" name="2: build_app" time="121.394124">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="91.795184">
<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;Invalid username and password combination. Used &apos;hey@dz0n.dev&apos; as the username." />
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="365.414242">
</testcase>

View File

@@ -1638,5 +1638,252 @@
"contactFailedToClearRoute": "Pfad konnte nicht gelöscht werden: {error}",
"contactRouteCleared": "Pfad gelöscht",
"contactRouteSet": "Pfad gesetzt: {route}",
"contactFailedToSetRoute": "Pfad konnte nicht gesetzt werden: {error}"
"contactFailedToSetRoute": "Pfad konnte nicht gesetzt werden: {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "ACK-Zeitüberschreitung",
"opcode": "Opcode",
"payload": "Nutzlast",
"hops": "Hops",
"hashSize": "Hash-Größe",
"pathBytes": "Pfad-Bytes",
"selectedPath": "Ausgewählter Pfad",
"estimatedTx": "Geschätzte Sendung",
"senderToReceipt": "Sender bis Empfang",
"receivedCopies": "Empfangene Kopien",
"retryCause": "Wiederholungsursache",
"retryMode": "Wiederholungsmodus",
"retryResult": "Wiederholungsergebnis",
"lastRetry": "Letzter Versuch",
"rxPackets": "RX-Pakete",
"mesh": "Mesh",
"rate": "Rate",
"window": "Fenster",
"posttxDelay": "Sendeverzögerung",
"bandpass": "Bandpass",
"bandpassFilterVoice": "Bandpassfilter Sprache",
"frequency": "Frequenz",
"australia": "Australien",
"australiaNarrow": "Australien (Schmal)",
"australiaQld": "Australien: QLD",
"australiaSaWa": "Australien: SA, WA",
"newZealand": "Neuseeland",
"newZealandNarrow": "Neuseeland (Schmal)",
"switzerland": "Schweiz",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "Tschechien (Schmal)",
"eu433mhzLongRange": "EU 433MHz (Langstrecke)",
"euukDeprecated": "EU/UK (Veraltet)",
"euukNarrow": "EU/UK (Schmal)",
"usacanadaRecommended": "USA/Kanada (Empfohlen)",
"vietnamDeprecated": "Vietnam (Veraltet)",
"vietnamNarrow": "Vietnam (Schmal)",
"active": "Aktiv",
"addContact": "Kontakt hinzufügen",
"all": "Alle",
"autoResolve": "Automatisch auflösen",
"clearAllLabel": "Alle löschen",
"clearRelays": "Relais löschen",
"clearFilters": "Filter löschen",
"clearRoute": "Route löschen",
"clearMessages": "Nachrichten löschen",
"clearScale": "Maßstab löschen",
"clearDiscoveries": "Entdeckungen löschen",
"clearOnlineTraceDatabase": "Online-Trace-Datenbank löschen",
"clearAllChannels": "Alle Kanäle löschen",
"clearAllContacts": "Alle Kontakte löschen",
"clearChannels": "Kanäle löschen",
"clearContacts": "Kontakte löschen",
"clearPathOnMaxRetry": "Pfad bei max. Wiederholung löschen",
"create": "Erstellen",
"custom": "Benutzerdefiniert",
"defaultValue": "Standard",
"duplicate": "Duplizieren",
"editName": "Name bearbeiten",
"open": "Öffnen",
"paste": "Einfügen",
"preview": "Vorschau",
"remove": "Entfernen",
"rename": "Umbenennen",
"resolveAll": "Alle auflösen",
"send": "Senden",
"sendAnyway": "Trotzdem senden",
"share": "Teilen",
"shareContact": "Kontakt teilen",
"trace": "Trace",
"use": "Verwenden",
"useSelectedFrequency": "Ausgewählte Frequenz verwenden",
"discovery": "Erkennung",
"discoverRepeaters": "Repeater entdecken",
"discoverSensors": "Sensoren entdecken",
"repeaterDiscoverySent": "Repeater-Erkennung gesendet",
"sensorDiscoverySent": "Sensor-Erkennung gesendet",
"clearedPendingDiscoveries": "Ausstehende Entdeckungen gelöscht.",
"autoDiscovery": "Automatische Erkennung",
"enableAutomaticAdding": "Automatisches Hinzufügen aktivieren",
"autoaddRepeaters": "Repeater automatisch hinzufügen",
"autoaddRoomServers": "Raumserver automatisch hinzufügen",
"autoaddSensors": "Sensoren automatisch hinzufügen",
"autoaddUsers": "Benutzer automatisch hinzufügen",
"overwriteOldestWhenFull": "Älteste bei vollem Speicher überschreiben",
"storage": "Speicher",
"dangerZone": "Gefahrenbereich",
"profiles": "Profile",
"favourites": "Favoriten",
"sensors": "Sensoren",
"others": "Andere",
"gpsModule": "GPS-Modul",
"liveTraffic": "Live-Verkehr",
"repeatersMap": "Repeater-Karte",
"spectrumScan": "Spektrum-Scan",
"blePacketLogs": "BLE-Paketprotokolle",
"onlineTraceDatabase": "Online-Trace-Datenbank",
"routePathByteSize": "Pfad-Bytegröße",
"messageNotifications": "Nachrichtenbenachrichtigungen",
"sarAlerts": "SAR-Alarme",
"discoveryNotifications": "Erkennungsbenachrichtigungen",
"updateNotifications": "Updatebenachrichtigungen",
"muteWhileAppIsOpen": "Stumm bei geöffneter App",
"disableContacts": "Kontakte deaktivieren",
"enableSensorsTab": "Sensoren-Tab aktivieren",
"enableProfiles": "Profile aktivieren",
"autoRouteRotation": "Automatische Routenrotation",
"nearestRepeaterFallback": "Nächster Repeater als Fallback",
"deleteAllStoredMessageHistory": "Gesamten Nachrichtenverlauf löschen",
"messageFontSize": "Nachrichtenschriftgröße",
"rotateMapWithHeading": "Karte mit Richtung drehen",
"showMapDebugInfo": "Karten-Debug-Info anzeigen",
"openMapInFullscreen": "Karte im Vollbild öffnen",
"showSarMarkersLabel": "SAR-Markierungen anzeigen",
"displaySarMarkersOnTheMainMap": "SAR-Markierungen auf der Hauptkarte anzeigen",
"showAllContactTrailsLabel": "Alle Kontaktpfade anzeigen",
"hideRepeatersOnMap": "Repeater auf Karte ausblenden",
"setMapScale": "Kartenmaßstab einstellen",
"customMapScaleSaved": "Benutzerdefinierter Kartenmaßstab gespeichert",
"voiceBitrate": "Sprach-Bitrate",
"voiceCompressor": "Sprachkompressor",
"balancesQuietAndLoudSpeechLevels": "Gleicht leise und laute Sprache aus",
"voiceLimiter": "Sprach-Limiter",
"preventsClippingPeaksBeforeEncoding": "Verhindert Clipping-Spitzen vor der Kodierung",
"micAutoGain": "Mikrofon-Automatik",
"letsTheRecorderAdjustInputLevel": "Lässt den Rekorder den Eingangspegel anpassen",
"echoCancellation": "Echounterdrückung",
"noiseSuppression": "Rauschunterdrückung",
"trimSilenceInVoiceMessages": "Stille in Sprachnachrichten trimmen",
"compressor": "Kompressor",
"limiter": "Limiter",
"autoGain": "Automatische Verstärkung",
"echoCancel": "Echo",
"noiseSuppress": "Rauschen",
"silenceTrim": "Stille",
"maxImageSize": "Maximale Bildgröße",
"imageCompression": "Bildkomprimierung",
"grayscale": "Graustufen",
"ultraMode": "Ultra-Modus",
"fastPrivateGpsUpdates": "Schnelle private GPS-Updates",
"movementThreshold": "Bewegungsschwelle",
"fastGpsMovementThreshold": "Schneller GPS-Bewegungsschwellenwert",
"fastGpsActiveuseInterval": "Schnelles GPS aktive Nutzung Intervall",
"activeuseUpdateInterval": "Aktive Nutzung Updateintervall",
"repeatNearbyTraffic": "Nahverkehr wiederholen",
"relayThroughRepeatersAcrossTheMesh": "Über Repeater im Mesh weiterleiten",
"nearbyOnlyWithoutRepeaterFlooding": "Nur in der Nähe, ohne Repeater-Flooding",
"multihop": "Multi-Hop",
"createProfile": "Profil erstellen",
"renameProfile": "Profil umbenennen",
"newProfile": "Neues Profil",
"manageProfiles": "Profile verwalten",
"enableProfilesToStartManagingThem": "Aktivieren Sie Profile, um sie zu verwalten.",
"openMessage": "Nachricht öffnen",
"jumpToTheRelatedSarMessage": "Zur zugehörigen SAR-Nachricht springen",
"removeSarMarker": "SAR-Markierung entfernen",
"pleaseSelectADestinationToSendSarMarker": "Bitte wählen Sie ein Ziel zum Senden der SAR-Markierung",
"sarMarkerBroadcastToPublicChannel": "SAR-Markierung an öffentlichen Kanal gesendet",
"sarMarkerSentToRoom": "SAR-Markierung an Raum gesendet",
"loadFromGallery": "Aus Galerie laden",
"replaceImage": "Bild ersetzen",
"selectFromGallery": "Aus Galerie auswählen",
"team": "Team",
"found": "Gefunden",
"staging": "Sammelpunkt",
"object": "Objekt",
"quiet": "Ruhig",
"moderate": "Mäßig",
"busy": "Beschäftigt",
"spectrumScanReturnedNoCandidateFrequencies": "Spektrum-Scan hat keine Kandidatenfrequenzen gefunden",
"searchMessages": "Nachrichten suchen",
"sendImageFromGallery": "Bild aus Galerie senden",
"takePhoto": "Foto aufnehmen",
"dmOnly": "Nur Direktnachricht",
"allMessages": "Alle Nachrichten",
"sendToPublicChannel": "An öffentlichen Kanal senden?",
"selectMarkerTypeAndDestination": "Markierungstyp und Ziel auswählen",
"noDestinationsAvailableLabel": "Keine Ziele verfügbar",
"image": "Bild",
"format": "Format",
"dimensions": "Abmessungen",
"segments": "Segmente",
"transfers": "Übertragungen",
"downloadedBy": "Heruntergeladen von",
"saveDiscoverySettings": "Erkennungseinstellungen speichern",
"savePublicInfo": "Öffentliche Info speichern",
"saveRadioSettings": "Funkeinstellungen speichern",
"savePath": "Pfad speichern",
"wipeDeviceData": "Gerätedaten löschen",
"wipeDevice": "Gerät löschen",
"destructiveDeviceActions": "Destruktive Geräteaktionen.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Wählen Sie eine Voreinstellung oder passen Sie die Funkeinstellungen an.",
"chooseTheNameAndLocationThisDeviceShares": "Wählen Sie den Namen und Standort, den dieses Gerät teilt.",
"availableSpaceOnThisDevice": "Verfügbarer Speicherplatz auf diesem Gerät.",
"used": "Belegt",
"total": "Gesamt",
"renameValue": "Wert umbenennen",
"customizeFields": "Felder anpassen",
"livePreview": "Live-Vorschau",
"refreshSchedule": "Aktualisierungsplan",
"noResponse": "Keine Antwort",
"refreshing": "Wird aktualisiert",
"unavailable": "Nicht verfügbar",
"pickARelayOrNodeToWatchInSensors": "Wählen Sie einen Relay oder Knoten zur Beobachtung.",
"publicKeyLabel": "Öffentlicher Schlüssel",
"alreadyInContacts": "Bereits in Kontakten",
"connectToADeviceBeforeAddingContacts": "Verbinden Sie sich mit einem Gerät, bevor Sie Kontakte hinzufügen",
"fromContacts": "Aus Kontakten",
"onlineOnly": "Nur online",
"inBoth": "In beiden",
"source": "Quelle",
"manualRouteEdit": "Manuelle Routenbearbeitung",
"observedMeshRoute": "Beobachtete Mesh-Route",
"allMessagesCleared": "Alle Nachrichten gelöscht",
"onlineTraceDatabaseCleared": "Online-Trace-Datenbank gelöscht",
"packetLogsCleared": "Paketprotokolle gelöscht",
"hexDataCopiedToClipboard": "Hex-Daten in die Zwischenablage kopiert",
"developerModeEnabled": "Entwicklermodus aktiviert",
"developerModeDisabled": "Entwicklermodus deaktiviert",
"clipboardIsEmpty": "Zwischenablage ist leer",
"contactImported": "Kontakt importiert",
"contactLinkCopiedToClipboard": "Kontaktlink in Zwischenablage kopiert",
"failedToExportContact": "Kontaktexport fehlgeschlagen",
"noLogsToExport": "Keine Protokolle zum Exportieren",
"exportAsCsv": "Als CSV exportieren",
"exportAsText": "Als Text exportieren",
"receivedRfc3339": "Empfangen (RFC3339)",
"buildTime": "Build-Zeit",
"downloadUrlNotAvailable": "Download-URL nicht verfügbar",
"cannotOpenDownloadUrl": "Download-URL kann nicht geöffnet werden",
"updateCheckIsOnlyAvailableOnAndroid": "Update-Prüfung nur auf Android verfügbar",
"youAreRunningTheLatestVersion": "Sie verwenden die neueste Version",
"updateAvailableButDownloadUrlNotFound": "Update verfügbar, aber Download-URL nicht gefunden",
"startTictactoe": "Tic-Tac-Toe starten",
"tictactoeUnavailable": "Tic-Tac-Toe nicht verfügbar",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: Gegner unbekannt",
"tictactoeWaitingForStart": "Tic-Tac-Toe: Warten auf Start",
"acceptsShareLinks": "Akzeptiert freigegebene Links",
"supportsRawHex": "Unterstützt rohes Hex",
"clipboardfriendly": "Zwischenablage-freundlich",
"captured": "Erfasst",
"size": "Größe",
"noCustomChannelsToClear": "Keine benutzerdefinierten Kanäle zum Löschen.",
"noDeviceContactsToClear": "Keine Gerätekontakte zum Löschen."
}

View File

@@ -2303,5 +2303,252 @@
"contactFailedToClearRoute": "Αποτυχία εκκαθάρισης διαδρομής: {error}",
"contactRouteCleared": "Η διαδρομή εκκαθαρίστηκε",
"contactRouteSet": "Ορίστηκε διαδρομή: {route}",
"contactFailedToSetRoute": "Αποτυχία ορισμού διαδρομής: {error}"
"contactFailedToSetRoute": "Αποτυχία ορισμού διαδρομής: {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "Λήξη χρόνου ACK",
"opcode": "Opcode",
"payload": "Ωφέλιμο φορτίο",
"hops": "Αναπηδήσεις",
"hashSize": "Μέγεθος hash",
"pathBytes": "Bytes διαδρομής",
"selectedPath": "Επιλεγμένη διαδρομή",
"estimatedTx": "Εκτιμώμενη εκπομπή",
"senderToReceipt": "Αποστολέας σε απόδειξη",
"receivedCopies": "Ληφθέντα αντίγραφα",
"retryCause": "Αιτία επανάληψης",
"retryMode": "Λειτουργία επανάληψης",
"retryResult": "Αποτέλεσμα επανάληψης",
"lastRetry": "Τελευταία επανάληψη",
"rxPackets": "Πακέτα RX",
"mesh": "Mesh",
"rate": "Ρυθμός",
"window": "Παράθυρο",
"posttxDelay": "Καθυστέρηση μετά-tx",
"bandpass": "Ζωνοπερατό",
"bandpassFilterVoice": "Ζωνοπερατό φίλτρο φωνής",
"frequency": "Συχνότητα",
"australia": "Αυστραλία",
"australiaNarrow": "Αυστραλία (Στενό)",
"australiaQld": "Αυστραλία: QLD",
"australiaSaWa": "Αυστραλία: SA, WA",
"newZealand": "Νέα Ζηλανδία",
"newZealandNarrow": "Νέα Ζηλανδία (Στενό)",
"switzerland": "Ελβετία",
"portugal433": "Πορτογαλία 433",
"portugal868": "Πορτογαλία 868",
"czechRepublicNarrow": "Τσεχία (Στενό)",
"eu433mhzLongRange": "ΕΕ 433MHz (Μεγάλη Εμβέλεια)",
"euukDeprecated": "ΕΕ/ΗΒ (Παρωχημένο)",
"euukNarrow": "ΕΕ/ΗΒ (Στενό)",
"usacanadaRecommended": "ΗΠΑ/Καναδάς (Προτεινόμενο)",
"vietnamDeprecated": "Βιετνάμ (Παρωχημένο)",
"vietnamNarrow": "Βιετνάμ (Στενό)",
"active": "Ενεργό",
"addContact": "Προσθήκη επαφής",
"all": "Όλα",
"autoResolve": "Αυτόματη επίλυση",
"clearAllLabel": "Εκκαθάριση όλων",
"clearRelays": "Εκκαθάριση αναμεταδοτών",
"clearFilters": "Εκκαθάριση φίλτρων",
"clearRoute": "Εκκαθάριση διαδρομής",
"clearMessages": "Εκκαθάριση μηνυμάτων",
"clearScale": "Εκκαθάριση κλίμακας",
"clearDiscoveries": "Εκκαθάριση ανακαλύψεων",
"clearOnlineTraceDatabase": "Εκκαθάριση βάσης ιχνών",
"clearAllChannels": "Εκκαθάριση όλων των καναλιών",
"clearAllContacts": "Εκκαθάριση όλων των επαφών",
"clearChannels": "Εκκαθάριση καναλιών",
"clearContacts": "Εκκαθάριση επαφών",
"clearPathOnMaxRetry": "Εκκαθάριση μονοπατιού στο μέγιστο",
"create": "Δημιουργία",
"custom": "Προσαρμοσμένο",
"defaultValue": "Προεπιλογή",
"duplicate": "Αντιγραφή",
"editName": "Επεξεργασία ονόματος",
"open": "Άνοιγμα",
"paste": "Επικόλληση",
"preview": "Προεπισκόπηση",
"remove": "Αφαίρεση",
"rename": "Μετονομασία",
"resolveAll": "Επίλυση όλων",
"send": "Αποστολή",
"sendAnyway": "Αποστολή ούτως ή άλλως",
"share": "Κοινοποίηση",
"shareContact": "Κοινοποίηση επαφής",
"trace": "Ίχνος",
"use": "Χρήση",
"useSelectedFrequency": "Χρήση επιλεγμένης συχνότητας",
"discovery": "Ανακάλυψη",
"discoverRepeaters": "Ανακάλυψη αναμεταδοτών",
"discoverSensors": "Ανακάλυψη αισθητήρων",
"repeaterDiscoverySent": "Αποστολή ανακάλυψης αναμεταδοτών",
"sensorDiscoverySent": "Αποστολή ανακάλυψης αισθητήρων",
"clearedPendingDiscoveries": "Εκκαθάριση εκκρεμών ανακαλύψεων.",
"autoDiscovery": "Αυτόματη ανακάλυψη",
"enableAutomaticAdding": "Ενεργοποίηση αυτόματης προσθήκης",
"autoaddRepeaters": "Αυτόματη προσθήκη αναμεταδοτών",
"autoaddRoomServers": "Αυτόματη προσθήκη διακομιστών δωματίου",
"autoaddSensors": "Αυτόματη προσθήκη αισθητήρων",
"autoaddUsers": "Αυτόματη προσθήκη χρηστών",
"overwriteOldestWhenFull": "Αντικατάσταση παλαιότερων όταν γεμίσει",
"storage": "Αποθήκευση",
"dangerZone": "Ζώνη κινδύνου",
"profiles": "Προφίλ",
"favourites": "Αγαπημένα",
"sensors": "Αισθητήρες",
"others": "Άλλοι",
"gpsModule": "Μονάδα GPS",
"liveTraffic": "Ζωντανή κίνηση",
"repeatersMap": "Χάρτης αναμεταδοτών",
"spectrumScan": "Σάρωση φάσματος",
"blePacketLogs": "Αρχεία πακέτων BLE",
"onlineTraceDatabase": "Βάση δεδομένων ιχνών",
"routePathByteSize": "Μέγεθος διαδρομής σε bytes",
"messageNotifications": "Ειδοποιήσεις μηνυμάτων",
"sarAlerts": "Ειδοποιήσεις SAR",
"discoveryNotifications": "Ειδοποιήσεις ανακάλυψης",
"updateNotifications": "Ειδοποιήσεις ενημερώσεων",
"muteWhileAppIsOpen": "Σίγαση με ανοιχτή εφαρμογή",
"disableContacts": "Απενεργοποίηση επαφών",
"enableSensorsTab": "Ενεργοποίηση καρτέλας Αισθητήρων",
"enableProfiles": "Ενεργοποίηση προφίλ",
"autoRouteRotation": "Αυτόματη εναλλαγή διαδρομής",
"nearestRepeaterFallback": "Πλησιέστερος αναμεταδότης ως εφεδρεία",
"deleteAllStoredMessageHistory": "Διαγραφή όλου του ιστορικού",
"messageFontSize": "Μέγεθος γραμματοσειράς μηνυμάτων",
"rotateMapWithHeading": "Περιστροφή χάρτη με κατεύθυνση",
"showMapDebugInfo": "Εμφάνιση πληροφοριών αποσφαλμάτωσης",
"openMapInFullscreen": "Άνοιγμα χάρτη σε πλήρη οθόνη",
"showSarMarkersLabel": "Εμφάνιση δεικτών SAR",
"displaySarMarkersOnTheMainMap": "Εμφάνιση δεικτών SAR στον κύριο χάρτη",
"showAllContactTrailsLabel": "Εμφάνιση όλων των ιχνών επαφών",
"hideRepeatersOnMap": "Απόκρυψη αναμεταδοτών στον χάρτη",
"setMapScale": "Ρύθμιση κλίμακας χάρτη",
"customMapScaleSaved": "Προσαρμοσμένη κλίμακα χάρτη αποθηκεύτηκε",
"voiceBitrate": "Ρυθμός bit φωνής",
"voiceCompressor": "Συμπιεστής φωνής",
"balancesQuietAndLoudSpeechLevels": "Εξισορρόπηση χαμηλών και δυνατών επιπέδων",
"voiceLimiter": "Περιοριστής φωνής",
"preventsClippingPeaksBeforeEncoding": "Αποτρέπει κοπή κορυφών πριν την κωδικοποίηση",
"micAutoGain": "Αυτόματη ενίσχυση μικροφώνου",
"letsTheRecorderAdjustInputLevel": "Αφήνει τη συσκευή να ρυθμίσει την ένταση",
"echoCancellation": "Ακύρωση ηχούς",
"noiseSuppression": "Καταστολή θορύβου",
"trimSilenceInVoiceMessages": "Περικοπή σιωπής σε φωνητικά μηνύματα",
"compressor": "Συμπιεστής",
"limiter": "Περιοριστής",
"autoGain": "Αυτόματη ενίσχυση",
"echoCancel": "Ηχώ",
"noiseSuppress": "Θόρυβος",
"silenceTrim": "Σιωπή",
"maxImageSize": "Μέγιστο μέγεθος εικόνας",
"imageCompression": "Συμπίεση εικόνας",
"grayscale": "Κλίμακα του γκρι",
"ultraMode": "Λειτουργία ultra",
"fastPrivateGpsUpdates": "Γρήγορες ιδιωτικές ενημερώσεις GPS",
"movementThreshold": "Κατώφλι κίνησης",
"fastGpsMovementThreshold": "Κατώφλι κίνησης γρήγορου GPS",
"fastGpsActiveuseInterval": "Διάστημα ενεργής χρήσης γρήγορου GPS",
"activeuseUpdateInterval": "Διάστημα ενημέρωσης ενεργής χρήσης",
"repeatNearbyTraffic": "Επανάληψη κοντινής κίνησης",
"relayThroughRepeatersAcrossTheMesh": "Αναμετάδοση μέσω αναμεταδοτών στο δίκτυο",
"nearbyOnlyWithoutRepeaterFlooding": "Μόνο κοντά, χωρίς πλημμυρισμό",
"multihop": "Πολλαπλών αναπηδήσεων",
"createProfile": "Δημιουργία προφίλ",
"renameProfile": "Μετονομασία προφίλ",
"newProfile": "Νέο προφίλ",
"manageProfiles": "Διαχείριση προφίλ",
"enableProfilesToStartManagingThem": "Ενεργοποιήστε τα προφίλ για να τα διαχειριστείτε.",
"openMessage": "Άνοιγμα μηνύματος",
"jumpToTheRelatedSarMessage": "Μετάβαση στο σχετικό μήνυμα SAR",
"removeSarMarker": "Αφαίρεση δείκτη SAR",
"pleaseSelectADestinationToSendSarMarker": "Επιλέξτε προορισμό για αποστολή δείκτη SAR",
"sarMarkerBroadcastToPublicChannel": "Δείκτης SAR εκπεμπόμενος στο δημόσιο κανάλι",
"sarMarkerSentToRoom": "Δείκτης SAR εστάλη στο δωμάτιο",
"loadFromGallery": "Φόρτωση από τη γκαλερί",
"replaceImage": "Αντικατάσταση εικόνας",
"selectFromGallery": "Επιλογή από τη γκαλερί",
"team": "Ομάδα",
"found": "Βρέθηκε",
"staging": "Χώρος συγκέντρωσης",
"object": "Αντικείμενο",
"quiet": "Ήσυχο",
"moderate": "Μέτριο",
"busy": "Απασχολημένο",
"spectrumScanReturnedNoCandidateFrequencies": "Η σάρωση φάσματος δεν βρήκε υποψήφιες συχνότητες",
"searchMessages": "Αναζήτηση μηνυμάτων",
"sendImageFromGallery": "Αποστολή εικόνας από τη γκαλερί",
"takePhoto": "Λήψη φωτογραφίας",
"dmOnly": "Μόνο απευθείας",
"allMessages": "Όλα τα μηνύματα",
"sendToPublicChannel": "Αποστολή στο δημόσιο κανάλι;",
"selectMarkerTypeAndDestination": "Επιλέξτε τύπο δείκτη και προορισμό",
"noDestinationsAvailableLabel": "Δεν υπάρχουν διαθέσιμοι προορισμοί",
"image": "Εικόνα",
"format": "Μορφή",
"dimensions": "Διαστάσεις",
"segments": "Τμήματα",
"transfers": "Μεταφορές",
"downloadedBy": "Λήφθηκε από",
"saveDiscoverySettings": "Αποθήκευση ρυθμίσεων ανακάλυψης",
"savePublicInfo": "Αποθήκευση δημόσιων πληροφοριών",
"saveRadioSettings": "Αποθήκευση ρυθμίσεων ραδιοφώνου",
"savePath": "Αποθήκευση διαδρομής",
"wipeDeviceData": "Διαγραφή δεδομένων συσκευής",
"wipeDevice": "Διαγραφή συσκευής",
"destructiveDeviceActions": "Καταστροφικές ενέργειες συσκευής.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Επιλέξτε μια προεπιλογή ή ρυθμίστε τις ρυθμίσεις ραδιοφώνου.",
"chooseTheNameAndLocationThisDeviceShares": "Επιλέξτε το όνομα και την τοποθεσία που μοιράζεται αυτή η συσκευή.",
"availableSpaceOnThisDevice": "Διαθέσιμος χώρος σε αυτή τη συσκευή.",
"used": "Χρησιμοποιήθηκε",
"total": "Σύνολο",
"renameValue": "Μετονομασία τιμής",
"customizeFields": "Προσαρμογή πεδίων",
"livePreview": "Ζωντανή προεπισκόπηση",
"refreshSchedule": "Χρονοδιάγραμμα ανανέωσης",
"noResponse": "Χωρίς απάντηση",
"refreshing": "Ανανέωση",
"unavailable": "Μη διαθέσιμο",
"pickARelayOrNodeToWatchInSensors": "Επιλέξτε ένα relay ή κόμβο για παρακολούθηση.",
"publicKeyLabel": "Δημόσιο κλειδί",
"alreadyInContacts": "Ήδη στις επαφές",
"connectToADeviceBeforeAddingContacts": "Συνδεθείτε σε μια συσκευή πριν προσθέσετε επαφές",
"fromContacts": "Από τις επαφές",
"onlineOnly": "Μόνο συνδεδεμένοι",
"inBoth": "Σε αμφότερα",
"source": "Πηγή",
"manualRouteEdit": "Χειροκίνητη επεξεργασία διαδρομής",
"observedMeshRoute": "Παρατηρούμενη διαδρομή δικτύου",
"allMessagesCleared": "Όλα τα μηνύματα διαγράφηκαν",
"onlineTraceDatabaseCleared": "Βάση δεδομένων ιχνών εκκαθαρίστηκε",
"packetLogsCleared": "Τα αρχεία πακέτων εκκαθαρίστηκαν",
"hexDataCopiedToClipboard": "Τα δεδομένα hex αντιγράφηκαν",
"developerModeEnabled": "Λειτουργία προγραμματιστή ενεργοποιήθηκε",
"developerModeDisabled": "Λειτουργία προγραμματιστή απενεργοποιήθηκε",
"clipboardIsEmpty": "Το πρόχειρο είναι κενό",
"contactImported": "Επαφή εισήχθη",
"contactLinkCopiedToClipboard": "Ο σύνδεσμος επαφής αντιγράφηκε",
"failedToExportContact": "Αποτυχία εξαγωγής επαφής",
"noLogsToExport": "Δεν υπάρχουν αρχεία για εξαγωγή",
"exportAsCsv": "Εξαγωγή ως CSV",
"exportAsText": "Εξαγωγή ως κείμενο",
"receivedRfc3339": "Ελήφθη (RFC3339)",
"buildTime": "Ώρα κατασκευής",
"downloadUrlNotAvailable": "Η URL λήψης δεν είναι διαθέσιμη",
"cannotOpenDownloadUrl": "Αδυναμία ανοίγματος URL λήψης",
"updateCheckIsOnlyAvailableOnAndroid": "Ο έλεγχος ενημερώσεων είναι διαθέσιμος μόνο σε Android",
"youAreRunningTheLatestVersion": "Χρησιμοποιείτε την πιο πρόσφατη έκδοση",
"updateAvailableButDownloadUrlNotFound": "Διαθέσιμη ενημέρωση αλλά η URL λήψης δεν βρέθηκε",
"startTictactoe": "Εκκίνηση Tic-Tac-Toe",
"tictactoeUnavailable": "Tic-Tac-Toe μη διαθέσιμο",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: άγνωστος αντίπαλος",
"tictactoeWaitingForStart": "Tic-Tac-Toe: αναμονή για εκκίνηση",
"acceptsShareLinks": "Αποδέχεται κοινόχρηστους συνδέσμους",
"supportsRawHex": "Υποστηρίζει ακατέργαστο hex",
"clipboardfriendly": "Φιλικό προς πρόχειρο",
"captured": "Καταγράφηκε",
"size": "Μέγεθος",
"noCustomChannelsToClear": "Δεν υπάρχουν προσαρμοσμένα κανάλια.",
"noDeviceContactsToClear": "Δεν υπάρχουν επαφές συσκευής."
}

View File

@@ -2228,80 +2228,55 @@
"description": "Message when there are no chat contacts to send to"
},
"reply": "Reply",
"@reply": {
},
"@reply": {},
"technicalDetails": "Technical details",
"@technicalDetails": {
},
"@technicalDetails": {},
"messageTechnicalDetails": "Message technical details",
"@messageTechnicalDetails": {
},
"@messageTechnicalDetails": {},
"linkQuality": "Link quality",
"@linkQuality": {
},
"@linkQuality": {},
"delivery": "Delivery",
"@delivery": {
},
"@delivery": {},
"status": "Status",
"@status": {
},
"@status": {},
"expectedAckTag": "Expected ACK tag",
"@expectedAckTag": {
},
"@expectedAckTag": {},
"roundTrip": "Round-trip",
"@roundTrip": {
},
"@roundTrip": {},
"retryAttempt": "Retry attempt",
"@retryAttempt": {
},
"@retryAttempt": {},
"floodFallback": "Flood fallback",
"@floodFallback": {
},
"@floodFallback": {},
"identity": "Identity",
"@identity": {
},
"@identity": {},
"messageId": "Message ID",
"@messageId": {
},
"@messageId": {},
"sender": "Sender",
"@sender": {
},
"@sender": {},
"senderKey": "Sender key",
"@senderKey": {
},
"@senderKey": {},
"recipient": "Recipient",
"@recipient": {
},
"@recipient": {},
"recipientKey": "Recipient key",
"@recipientKey": {
},
"@recipientKey": {},
"voice": "Voice",
"@voice": {
},
"@voice": {},
"voiceId": "Voice ID",
"@voiceId": {
},
"@voiceId": {},
"envelope": "Envelope",
"@envelope": {
},
"@envelope": {},
"sessionProgress": "Session progress",
"@sessionProgress": {
},
"@sessionProgress": {},
"complete": "Complete",
"@complete": {
},
"@complete": {},
"rawDump": "Raw dump",
"@rawDump": {
},
"@rawDump": {},
"cannotRetryMissingRecipient": "Cannot retry: recipient information missing",
"@cannotRetryMissingRecipient": {
},
"@cannotRetryMissingRecipient": {},
"voiceUnavailable": "Voice unavailable right now",
"@voiceUnavailable": {
},
"@voiceUnavailable": {},
"requestingVoice": "Requesting voice",
"@requestingVoice": {
},
"@requestingVoice": {},
"device": "device",
"@device": {
"description": "Generic device label"
@@ -2542,5 +2517,258 @@
"type": "String"
}
}
}
},
"rssi": "RSSI",
"@rssi": {
"description": "Received signal strength indicator"
},
"snr": "SNR",
"@snr": {
"description": "Signal-to-noise ratio"
},
"ackTimeout": "ACK timeout",
"opcode": "Opcode",
"payload": "Payload",
"hops": "Hops",
"hashSize": "Hash size",
"pathBytes": "Path bytes",
"selectedPath": "Selected path",
"estimatedTx": "Estimated tx",
"senderToReceipt": "Sender to receipt",
"receivedCopies": "Received copies",
"retryCause": "Retry cause",
"retryMode": "Retry mode",
"retryResult": "Retry result",
"lastRetry": "Last retry",
"rxPackets": "RX packets",
"mesh": "Mesh",
"rate": "Rate",
"window": "Window",
"posttxDelay": "Post-tx delay",
"bandpass": "Band-pass",
"bandpassFilterVoice": "Band-pass filter voice",
"frequency": "Frequency",
"australia": "Australia",
"australiaNarrow": "Australia (Narrow)",
"australiaQld": "Australia: QLD",
"australiaSaWa": "Australia: SA, WA",
"newZealand": "New Zealand",
"newZealandNarrow": "New Zealand (Narrow)",
"switzerland": "Switzerland",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "Czech Republic (Narrow)",
"eu433mhzLongRange": "EU 433MHz (Long Range)",
"euukDeprecated": "EU/UK (Deprecated)",
"euukNarrow": "EU/UK (Narrow)",
"usacanadaRecommended": "USA/Canada (Recommended)",
"vietnamDeprecated": "Vietnam (Deprecated)",
"vietnamNarrow": "Vietnam (Narrow)",
"active": "Active",
"addContact": "Add Contact",
"all": "All",
"autoResolve": "Auto resolve",
"clearAllLabel": "Clear all",
"clearRelays": "Clear relays",
"clearFilters": "Clear filters",
"clearRoute": "Clear Route",
"clearMessages": "Clear Messages",
"clearScale": "Clear scale",
"clearDiscoveries": "Clear discoveries",
"clearOnlineTraceDatabase": "Clear online trace database",
"clearAllChannels": "Clear all channels",
"clearAllContacts": "Clear all contacts",
"clearChannels": "Clear channels",
"clearContacts": "Clear contacts",
"clearPathOnMaxRetry": "Clear path on max retry",
"create": "Create",
"custom": "Custom",
"defaultValue": "Default",
"duplicate": "Duplicate",
"editName": "Edit name",
"open": "Open",
"paste": "Paste",
"preview": "Preview",
"remove": "Remove",
"rename": "Rename",
"resolveAll": "Resolve all",
"send": "Send",
"sendAnyway": "Send anyway",
"share": "Share",
"shareContact": "Share Contact",
"trace": "Trace",
"use": "Use",
"useSelectedFrequency": "Use selected frequency",
"discovery": "Discovery",
"discoverRepeaters": "Discover repeaters",
"discoverSensors": "Discover sensors",
"repeaterDiscoverySent": "Repeater discovery sent",
"sensorDiscoverySent": "Sensor discovery sent",
"clearedPendingDiscoveries": "Cleared pending discoveries.",
"autoDiscovery": "Auto discovery",
"enableAutomaticAdding": "Enable automatic adding",
"autoaddRepeaters": "Auto-add repeaters",
"autoaddRoomServers": "Auto-add room servers",
"autoaddSensors": "Auto-add sensors",
"autoaddUsers": "Auto-add users",
"overwriteOldestWhenFull": "Overwrite oldest when full",
"storage": "Storage",
"dangerZone": "Danger zone",
"profiles": "Profiles",
"favourites": "Favourites",
"sensors": "Sensors",
"others": "Others",
"gpsModule": "GPS Module",
"liveTraffic": "Live Traffic",
"repeatersMap": "Repeaters Map",
"spectrumScan": "Spectrum Scan",
"blePacketLogs": "BLE Packet Logs",
"onlineTraceDatabase": "Online trace database",
"routePathByteSize": "Route path byte size",
"messageNotifications": "Message notifications",
"sarAlerts": "SAR alerts",
"discoveryNotifications": "Discovery notifications",
"updateNotifications": "Update notifications",
"muteWhileAppIsOpen": "Mute while app is open",
"disableContacts": "Disable Contacts",
"enableSensorsTab": "Enable Sensors tab",
"enableProfiles": "Enable Profiles",
"autoRouteRotation": "Auto route rotation",
"nearestRepeaterFallback": "Nearest repeater fallback",
"deleteAllStoredMessageHistory": "Delete all stored message history",
"messageFontSize": "Message font size",
"rotateMapWithHeading": "Rotate map with heading",
"showMapDebugInfo": "Show map debug info",
"openMapInFullscreen": "Open map in fullscreen",
"showSarMarkersLabel": "Show SAR markers",
"displaySarMarkersOnTheMainMap": "Display SAR markers on the main map",
"showAllContactTrailsLabel": "Show all contact trails",
"hideRepeatersOnMap": "Hide repeaters on map",
"setMapScale": "Set map scale",
"customMapScaleSaved": "Custom map scale saved",
"voiceBitrate": "Voice bitrate",
"voiceCompressor": "Voice compressor",
"balancesQuietAndLoudSpeechLevels": "Balances quiet and loud speech levels",
"voiceLimiter": "Voice limiter",
"preventsClippingPeaksBeforeEncoding": "Prevents clipping peaks before encoding",
"micAutoGain": "Mic auto gain",
"letsTheRecorderAdjustInputLevel": "Lets the recorder adjust input level",
"echoCancellation": "Echo cancellation",
"noiseSuppression": "Noise suppression",
"trimSilenceInVoiceMessages": "Trim silence in voice messages",
"compressor": "Compressor",
"limiter": "Limiter",
"autoGain": "Auto gain",
"echoCancel": "Echo cancel",
"noiseSuppress": "Noise suppress",
"silenceTrim": "Silence trim",
"maxImageSize": "Max image size",
"imageCompression": "Image compression",
"grayscale": "Grayscale",
"ultraMode": "Ultra mode",
"fastPrivateGpsUpdates": "Fast private GPS updates",
"movementThreshold": "Movement threshold",
"fastGpsMovementThreshold": "Fast GPS movement threshold",
"fastGpsActiveuseInterval": "Fast GPS active-use interval",
"activeuseUpdateInterval": "Active-use update interval",
"repeatNearbyTraffic": "Repeat nearby traffic",
"relayThroughRepeatersAcrossTheMesh": "Relay through repeaters across the mesh",
"nearbyOnlyWithoutRepeaterFlooding": "Nearby only, without repeater flooding",
"multihop": "Multi-hop",
"createProfile": "Create Profile",
"renameProfile": "Rename Profile",
"newProfile": "New Profile",
"manageProfiles": "Manage profiles",
"enableProfilesToStartManagingThem": "Enable profiles to start managing them.",
"openMessage": "Open message",
"jumpToTheRelatedSarMessage": "Jump to the related SAR message",
"removeSarMarker": "Remove SAR marker",
"pleaseSelectADestinationToSendSarMarker": "Please select a destination to send SAR marker",
"sarMarkerBroadcastToPublicChannel": "SAR marker broadcast to public channel",
"sarMarkerSentToRoom": "SAR marker sent to room",
"loadFromGallery": "Load from gallery",
"replaceImage": "Replace image",
"selectFromGallery": "Select from gallery",
"team": "Team",
"found": "Found",
"staging": "Staging",
"object": "Object",
"quiet": "Quiet",
"moderate": "Moderate",
"busy": "Busy",
"spectrumScanReturnedNoCandidateFrequencies": "Spectrum scan returned no candidate frequencies",
"searchMessages": "Search messages",
"sendImageFromGallery": "Send image from gallery",
"takePhoto": "Take photo",
"dmOnly": "DM only",
"allMessages": "All messages",
"sendToPublicChannel": "Send to Public Channel?",
"selectMarkerTypeAndDestination": "Select marker type and destination",
"noDestinationsAvailableLabel": "No destinations available",
"image": "Image",
"format": "Format",
"dimensions": "Dimensions",
"segments": "Segments",
"transfers": "Transfers",
"downloadedBy": "Downloaded by",
"saveDiscoverySettings": "Save discovery settings",
"savePublicInfo": "Save public info",
"saveRadioSettings": "Save radio settings",
"savePath": "Save Path",
"wipeDeviceData": "Wipe device data",
"wipeDevice": "Wipe device",
"destructiveDeviceActions": "Destructive device actions.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Choose a preset or fine-tune custom radio settings.",
"chooseTheNameAndLocationThisDeviceShares": "Choose the name and location this device shares.",
"availableSpaceOnThisDevice": "Available space on this device.",
"used": "Used",
"total": "Total",
"renameValue": "Rename value",
"customizeFields": "Customize fields",
"livePreview": "Live preview",
"refreshSchedule": "Refresh schedule",
"noResponse": "No response",
"refreshing": "Refreshing",
"unavailable": "Unavailable",
"pickARelayOrNodeToWatchInSensors": "Pick a relay or node to watch in Sensors.",
"publicKeyLabel": "Public key",
"alreadyInContacts": "Already in contacts",
"connectToADeviceBeforeAddingContacts": "Connect to a device before adding contacts",
"fromContacts": "From contacts",
"onlineOnly": "Online only",
"inBoth": "In both",
"source": "Source",
"manualRouteEdit": "Manual route edit",
"observedMeshRoute": "Observed mesh route",
"allMessagesCleared": "All messages cleared",
"onlineTraceDatabaseCleared": "Online trace database cleared",
"packetLogsCleared": "Packet logs cleared",
"hexDataCopiedToClipboard": "Hex data copied to clipboard",
"developerModeEnabled": "Developer mode enabled",
"developerModeDisabled": "Developer mode disabled",
"clipboardIsEmpty": "Clipboard is empty",
"contactImported": "Contact imported",
"contactLinkCopiedToClipboard": "Contact link copied to clipboard",
"failedToExportContact": "Failed to export contact",
"noLogsToExport": "No logs to export",
"exportAsCsv": "Export as CSV",
"exportAsText": "Export as Text",
"receivedRfc3339": "Received (RFC3339)",
"buildTime": "Build Time",
"downloadUrlNotAvailable": "Download URL not available",
"cannotOpenDownloadUrl": "Cannot open download URL",
"updateCheckIsOnlyAvailableOnAndroid": "Update check is only available on Android",
"youAreRunningTheLatestVersion": "You are running the latest version",
"updateAvailableButDownloadUrlNotFound": "Update available but download URL not found",
"startTictactoe": "Start Tic-Tac-Toe",
"tictactoeUnavailable": "Tic-Tac-Toe unavailable",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: opponent unknown",
"tictactoeWaitingForStart": "Tic-Tac-Toe: waiting for start",
"acceptsShareLinks": "Accepts share links",
"supportsRawHex": "Supports raw hex",
"clipboardfriendly": "Clipboard-friendly",
"captured": "Captured",
"size": "Size",
"noCustomChannelsToClear": "No custom channels to clear.",
"noDeviceContactsToClear": "No device contacts to clear."
}

View File

@@ -1638,5 +1638,252 @@
"contactFailedToClearRoute": "No se pudo borrar la ruta: {error}",
"contactRouteCleared": "Ruta borrada",
"contactRouteSet": "Ruta establecida: {route}",
"contactFailedToSetRoute": "No se pudo establecer la ruta: {error}"
"contactFailedToSetRoute": "No se pudo establecer la ruta: {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "Tiempo de espera ACK",
"opcode": "Código de operación",
"payload": "Carga útil",
"hops": "Saltos",
"hashSize": "Tamaño del hash",
"pathBytes": "Bytes de ruta",
"selectedPath": "Ruta seleccionada",
"estimatedTx": "Transmisión estimada",
"senderToReceipt": "Del remitente al recibo",
"receivedCopies": "Copias recibidas",
"retryCause": "Causa del reintento",
"retryMode": "Modo de reintento",
"retryResult": "Resultado del reintento",
"lastRetry": "Último reintento",
"rxPackets": "Paquetes RX",
"mesh": "Mesh",
"rate": "Velocidad",
"window": "Ventana",
"posttxDelay": "Retardo post-tx",
"bandpass": "Paso de banda",
"bandpassFilterVoice": "Filtro de paso de banda de voz",
"frequency": "Frecuencia",
"australia": "Australia",
"australiaNarrow": "Australia (Estrecho)",
"australiaQld": "Australia: QLD",
"australiaSaWa": "Australia: SA, WA",
"newZealand": "Nueva Zelanda",
"newZealandNarrow": "Nueva Zelanda (Estrecho)",
"switzerland": "Suiza",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "República Checa (Estrecho)",
"eu433mhzLongRange": "EU 433MHz (Largo Alcance)",
"euukDeprecated": "UE/RU (Obsoleto)",
"euukNarrow": "UE/RU (Estrecho)",
"usacanadaRecommended": "EE.UU./Canadá (Recomendado)",
"vietnamDeprecated": "Vietnam (Obsoleto)",
"vietnamNarrow": "Vietnam (Estrecho)",
"active": "Activo",
"addContact": "Añadir contacto",
"all": "Todo",
"autoResolve": "Resolver automáticamente",
"clearAllLabel": "Borrar todo",
"clearRelays": "Borrar repetidores",
"clearFilters": "Borrar filtros",
"clearRoute": "Borrar ruta",
"clearMessages": "Borrar mensajes",
"clearScale": "Borrar escala",
"clearDiscoveries": "Borrar descubrimientos",
"clearOnlineTraceDatabase": "Borrar base de datos de trazas",
"clearAllChannels": "Borrar todos los canales",
"clearAllContacts": "Borrar todos los contactos",
"clearChannels": "Borrar canales",
"clearContacts": "Borrar contactos",
"clearPathOnMaxRetry": "Borrar ruta en máximo reintento",
"create": "Crear",
"custom": "Personalizado",
"defaultValue": "Predeterminado",
"duplicate": "Duplicar",
"editName": "Editar nombre",
"open": "Abrir",
"paste": "Pegar",
"preview": "Vista previa",
"remove": "Eliminar",
"rename": "Renombrar",
"resolveAll": "Resolver todo",
"send": "Enviar",
"sendAnyway": "Enviar de todos modos",
"share": "Compartir",
"shareContact": "Compartir contacto",
"trace": "Traza",
"use": "Usar",
"useSelectedFrequency": "Usar frecuencia seleccionada",
"discovery": "Descubrimiento",
"discoverRepeaters": "Descubrir repetidores",
"discoverSensors": "Descubrir sensores",
"repeaterDiscoverySent": "Descubrimiento de repetidores enviado",
"sensorDiscoverySent": "Descubrimiento de sensores enviado",
"clearedPendingDiscoveries": "Descubrimientos pendientes borrados.",
"autoDiscovery": "Descubrimiento automático",
"enableAutomaticAdding": "Habilitar adición automática",
"autoaddRepeaters": "Añadir repetidores automáticamente",
"autoaddRoomServers": "Añadir servidores de sala automáticamente",
"autoaddSensors": "Añadir sensores automáticamente",
"autoaddUsers": "Añadir usuarios automáticamente",
"overwriteOldestWhenFull": "Sobrescribir más antiguos cuando esté lleno",
"storage": "Almacenamiento",
"dangerZone": "Zona de peligro",
"profiles": "Perfiles",
"favourites": "Favoritos",
"sensors": "Sensores",
"others": "Otros",
"gpsModule": "Módulo GPS",
"liveTraffic": "Tráfico en vivo",
"repeatersMap": "Mapa de repetidores",
"spectrumScan": "Escaneo de espectro",
"blePacketLogs": "Registros de paquetes BLE",
"onlineTraceDatabase": "Base de datos de trazas",
"routePathByteSize": "Tamaño en bytes de la ruta",
"messageNotifications": "Notificaciones de mensajes",
"sarAlerts": "Alertas SAR",
"discoveryNotifications": "Notificaciones de descubrimiento",
"updateNotifications": "Notificaciones de actualización",
"muteWhileAppIsOpen": "Silenciar con la app abierta",
"disableContacts": "Desactivar contactos",
"enableSensorsTab": "Habilitar pestaña Sensores",
"enableProfiles": "Habilitar perfiles",
"autoRouteRotation": "Rotación automática de ruta",
"nearestRepeaterFallback": "Repetidor más cercano como reserva",
"deleteAllStoredMessageHistory": "Eliminar todo el historial de mensajes",
"messageFontSize": "Tamaño de fuente de mensajes",
"rotateMapWithHeading": "Rotar mapa con rumbo",
"showMapDebugInfo": "Mostrar info de depuración del mapa",
"openMapInFullscreen": "Abrir mapa en pantalla completa",
"showSarMarkersLabel": "Mostrar marcadores SAR",
"displaySarMarkersOnTheMainMap": "Mostrar marcadores SAR en el mapa principal",
"showAllContactTrailsLabel": "Mostrar todos los rastros de contactos",
"hideRepeatersOnMap": "Ocultar repetidores en el mapa",
"setMapScale": "Establecer escala del mapa",
"customMapScaleSaved": "Escala de mapa personalizada guardada",
"voiceBitrate": "Tasa de bits de voz",
"voiceCompressor": "Compresor de voz",
"balancesQuietAndLoudSpeechLevels": "Equilibra niveles de habla suaves y altos",
"voiceLimiter": "Limitador de voz",
"preventsClippingPeaksBeforeEncoding": "Previene recorte de picos antes de codificar",
"micAutoGain": "Ganancia automática del micrófono",
"letsTheRecorderAdjustInputLevel": "Permite al grabador ajustar el nivel de entrada",
"echoCancellation": "Cancelación de eco",
"noiseSuppression": "Supresión de ruido",
"trimSilenceInVoiceMessages": "Recortar silencio en mensajes de voz",
"compressor": "Compresor",
"limiter": "Limitador",
"autoGain": "Ganancia automática",
"echoCancel": "Eco",
"noiseSuppress": "Ruido",
"silenceTrim": "Silencio",
"maxImageSize": "Tamaño máximo de imagen",
"imageCompression": "Compresión de imagen",
"grayscale": "Escala de grises",
"ultraMode": "Modo ultra",
"fastPrivateGpsUpdates": "Actualizaciones GPS privadas rápidas",
"movementThreshold": "Umbral de movimiento",
"fastGpsMovementThreshold": "Umbral de movimiento GPS rápido",
"fastGpsActiveuseInterval": "Intervalo de uso activo GPS rápido",
"activeuseUpdateInterval": "Intervalo de actualización de uso activo",
"repeatNearbyTraffic": "Repetir tráfico cercano",
"relayThroughRepeatersAcrossTheMesh": "Retransmitir a través de repetidores en la red",
"nearbyOnlyWithoutRepeaterFlooding": "Solo cercanos, sin inundación de repetidores",
"multihop": "Multi-salto",
"createProfile": "Crear perfil",
"renameProfile": "Renombrar perfil",
"newProfile": "Nuevo perfil",
"manageProfiles": "Gestionar perfiles",
"enableProfilesToStartManagingThem": "Habilite los perfiles para comenzar a gestionarlos.",
"openMessage": "Abrir mensaje",
"jumpToTheRelatedSarMessage": "Ir al mensaje SAR relacionado",
"removeSarMarker": "Eliminar marcador SAR",
"pleaseSelectADestinationToSendSarMarker": "Seleccione un destino para enviar el marcador SAR",
"sarMarkerBroadcastToPublicChannel": "Marcador SAR transmitido al canal público",
"sarMarkerSentToRoom": "Marcador SAR enviado a la sala",
"loadFromGallery": "Cargar desde galería",
"replaceImage": "Reemplazar imagen",
"selectFromGallery": "Seleccionar de galería",
"team": "Equipo",
"found": "Encontrado",
"staging": "Área de reunión",
"object": "Objeto",
"quiet": "Silencioso",
"moderate": "Moderado",
"busy": "Ocupado",
"spectrumScanReturnedNoCandidateFrequencies": "El escaneo de espectro no encontró frecuencias candidatas",
"searchMessages": "Buscar mensajes",
"sendImageFromGallery": "Enviar imagen desde galería",
"takePhoto": "Tomar foto",
"dmOnly": "Solo mensaje directo",
"allMessages": "Todos los mensajes",
"sendToPublicChannel": "¿Enviar al canal público?",
"selectMarkerTypeAndDestination": "Seleccione tipo de marcador y destino",
"noDestinationsAvailableLabel": "No hay destinos disponibles",
"image": "Imagen",
"format": "Formato",
"dimensions": "Dimensiones",
"segments": "Segmentos",
"transfers": "Transferencias",
"downloadedBy": "Descargado por",
"saveDiscoverySettings": "Guardar ajustes de descubrimiento",
"savePublicInfo": "Guardar información pública",
"saveRadioSettings": "Guardar ajustes de radio",
"savePath": "Guardar ruta",
"wipeDeviceData": "Borrar datos del dispositivo",
"wipeDevice": "Borrar dispositivo",
"destructiveDeviceActions": "Acciones destructivas del dispositivo.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Elija un preajuste o ajuste la configuración de radio.",
"chooseTheNameAndLocationThisDeviceShares": "Elija el nombre y la ubicación que comparte este dispositivo.",
"availableSpaceOnThisDevice": "Espacio disponible en este dispositivo.",
"used": "Usado",
"total": "Total",
"renameValue": "Renombrar valor",
"customizeFields": "Personalizar campos",
"livePreview": "Vista previa en vivo",
"refreshSchedule": "Programación de actualización",
"noResponse": "Sin respuesta",
"refreshing": "Actualizando",
"unavailable": "No disponible",
"pickARelayOrNodeToWatchInSensors": "Elija un relay o nodo para observar.",
"publicKeyLabel": "Clave pública",
"alreadyInContacts": "Ya en contactos",
"connectToADeviceBeforeAddingContacts": "Conéctese a un dispositivo antes de añadir contactos",
"fromContacts": "De contactos",
"onlineOnly": "Solo en línea",
"inBoth": "En ambos",
"source": "Fuente",
"manualRouteEdit": "Edición manual de ruta",
"observedMeshRoute": "Ruta mesh observada",
"allMessagesCleared": "Todos los mensajes borrados",
"onlineTraceDatabaseCleared": "Base de datos de trazas borrada",
"packetLogsCleared": "Registros de paquetes borrados",
"hexDataCopiedToClipboard": "Datos hex copiados al portapapeles",
"developerModeEnabled": "Modo desarrollador activado",
"developerModeDisabled": "Modo desarrollador desactivado",
"clipboardIsEmpty": "El portapapeles está vacío",
"contactImported": "Contacto importado",
"contactLinkCopiedToClipboard": "Enlace de contacto copiado al portapapeles",
"failedToExportContact": "Error al exportar el contacto",
"noLogsToExport": "No hay registros para exportar",
"exportAsCsv": "Exportar como CSV",
"exportAsText": "Exportar como texto",
"receivedRfc3339": "Recibido (RFC3339)",
"buildTime": "Hora de compilación",
"downloadUrlNotAvailable": "URL de descarga no disponible",
"cannotOpenDownloadUrl": "No se puede abrir la URL de descarga",
"updateCheckIsOnlyAvailableOnAndroid": "La comprobación de actualizaciones solo está disponible en Android",
"youAreRunningTheLatestVersion": "Está ejecutando la última versión",
"updateAvailableButDownloadUrlNotFound": "Actualización disponible pero URL de descarga no encontrada",
"startTictactoe": "Iniciar Tic-Tac-Toe",
"tictactoeUnavailable": "Tic-Tac-Toe no disponible",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: oponente desconocido",
"tictactoeWaitingForStart": "Tic-Tac-Toe: esperando inicio",
"acceptsShareLinks": "Acepta enlaces compartidos",
"supportsRawHex": "Soporta hex sin procesar",
"clipboardfriendly": "Compatible con portapapeles",
"captured": "Capturado",
"size": "Tamaño",
"noCustomChannelsToClear": "No hay canales personalizados que borrar.",
"noDeviceContactsToClear": "No hay contactos del dispositivo que borrar."
}

View File

@@ -1641,5 +1641,252 @@
"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}"
"contactFailedToSetRoute": "Échec du réglage de la route : {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "Délai ACK expiré",
"opcode": "Opcode",
"payload": "Charge utile",
"hops": "Sauts",
"hashSize": "Taille du hash",
"pathBytes": "Octets du chemin",
"selectedPath": "Chemin sélectionné",
"estimatedTx": "Émission estimée",
"senderToReceipt": "Émetteur au reçu",
"receivedCopies": "Copies reçues",
"retryCause": "Cause de la relance",
"retryMode": "Mode de relance",
"retryResult": "Résultat de la relance",
"lastRetry": "Dernière relance",
"rxPackets": "Paquets RX",
"mesh": "Mesh",
"rate": "Débit",
"window": "Fenêtre",
"posttxDelay": "Délai post-émission",
"bandpass": "Passe-bande",
"bandpassFilterVoice": "Filtre passe-bande voix",
"frequency": "Fréquence",
"australia": "Australie",
"australiaNarrow": "Australie (Étroit)",
"australiaQld": "Australie: QLD",
"australiaSaWa": "Australie: SA, WA",
"newZealand": "Nouvelle-Zélande",
"newZealandNarrow": "Nouvelle-Zélande (Étroit)",
"switzerland": "Suisse",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "République tchèque (Étroit)",
"eu433mhzLongRange": "UE 433MHz (Longue Portée)",
"euukDeprecated": "UE/RU (Obsolète)",
"euukNarrow": "UE/RU (Étroit)",
"usacanadaRecommended": "USA/Canada (Recommandé)",
"vietnamDeprecated": "Vietnam (Obsolète)",
"vietnamNarrow": "Vietnam (Étroit)",
"active": "Actif",
"addContact": "Ajouter un contact",
"all": "Tout",
"autoResolve": "Résolution automatique",
"clearAllLabel": "Tout effacer",
"clearRelays": "Effacer les relais",
"clearFilters": "Effacer les filtres",
"clearRoute": "Effacer la route",
"clearMessages": "Effacer les messages",
"clearScale": "Effacer l'échelle",
"clearDiscoveries": "Effacer les découvertes",
"clearOnlineTraceDatabase": "Effacer la base de traces",
"clearAllChannels": "Effacer tous les canaux",
"clearAllContacts": "Effacer tous les contacts",
"clearChannels": "Effacer les canaux",
"clearContacts": "Effacer les contacts",
"clearPathOnMaxRetry": "Effacer le chemin au max de relances",
"create": "Créer",
"custom": "Personnalisé",
"defaultValue": "Par défaut",
"duplicate": "Dupliquer",
"editName": "Modifier le nom",
"open": "Ouvrir",
"paste": "Coller",
"preview": "Aperçu",
"remove": "Supprimer",
"rename": "Renommer",
"resolveAll": "Résoudre tout",
"send": "Envoyer",
"sendAnyway": "Envoyer quand même",
"share": "Partager",
"shareContact": "Partager le contact",
"trace": "Trace",
"use": "Utiliser",
"useSelectedFrequency": "Utiliser la fréquence sélectionnée",
"discovery": "Découverte",
"discoverRepeaters": "Découvrir les répéteurs",
"discoverSensors": "Découvrir les capteurs",
"repeaterDiscoverySent": "Découverte de répéteurs envoyée",
"sensorDiscoverySent": "Découverte de capteurs envoyée",
"clearedPendingDiscoveries": "Découvertes en attente effacées.",
"autoDiscovery": "Découverte automatique",
"enableAutomaticAdding": "Activer l'ajout automatique",
"autoaddRepeaters": "Ajout auto des répéteurs",
"autoaddRoomServers": "Ajout auto des serveurs de salle",
"autoaddSensors": "Ajout auto des capteurs",
"autoaddUsers": "Ajout auto des utilisateurs",
"overwriteOldestWhenFull": "Écraser les plus anciens si plein",
"storage": "Stockage",
"dangerZone": "Zone de danger",
"profiles": "Profils",
"favourites": "Favoris",
"sensors": "Capteurs",
"others": "Autres",
"gpsModule": "Module GPS",
"liveTraffic": "Trafic en direct",
"repeatersMap": "Carte des répéteurs",
"spectrumScan": "Scan spectral",
"blePacketLogs": "Journaux de paquets BLE",
"onlineTraceDatabase": "Base de données de traces",
"routePathByteSize": "Taille en octets du chemin",
"messageNotifications": "Notifications de messages",
"sarAlerts": "Alertes SAR",
"discoveryNotifications": "Notifications de découverte",
"updateNotifications": "Notifications de mise à jour",
"muteWhileAppIsOpen": "Muet quand l'appli est ouverte",
"disableContacts": "Désactiver les contacts",
"enableSensorsTab": "Activer l'onglet Capteurs",
"enableProfiles": "Activer les profils",
"autoRouteRotation": "Rotation automatique de route",
"nearestRepeaterFallback": "Répéteur le plus proche en repli",
"deleteAllStoredMessageHistory": "Supprimer tout l'historique",
"messageFontSize": "Taille de police des messages",
"rotateMapWithHeading": "Tourner la carte avec le cap",
"showMapDebugInfo": "Afficher les infos de débogage",
"openMapInFullscreen": "Ouvrir la carte en plein écran",
"showSarMarkersLabel": "Afficher les marqueurs SAR",
"displaySarMarkersOnTheMainMap": "Afficher les marqueurs SAR sur la carte",
"showAllContactTrailsLabel": "Afficher toutes les traces de contacts",
"hideRepeatersOnMap": "Masquer les répéteurs sur la carte",
"setMapScale": "Définir l'échelle de la carte",
"customMapScaleSaved": "Échelle de carte personnalisée enregistrée",
"voiceBitrate": "Débit vocal",
"voiceCompressor": "Compresseur vocal",
"balancesQuietAndLoudSpeechLevels": "Équilibre les niveaux faibles et forts",
"voiceLimiter": "Limiteur vocal",
"preventsClippingPeaksBeforeEncoding": "Empêche l'écrêtage avant l'encodage",
"micAutoGain": "Gain auto du micro",
"letsTheRecorderAdjustInputLevel": "Laisse l'enregistreur ajuster le niveau d'entrée",
"echoCancellation": "Annulation d'écho",
"noiseSuppression": "Suppression du bruit",
"trimSilenceInVoiceMessages": "Couper le silence dans les messages vocaux",
"compressor": "Compresseur",
"limiter": "Limiteur",
"autoGain": "Gain automatique",
"echoCancel": "Écho",
"noiseSuppress": "Bruit",
"silenceTrim": "Silence",
"maxImageSize": "Taille d'image maximale",
"imageCompression": "Compression d'image",
"grayscale": "Niveaux de gris",
"ultraMode": "Mode ultra",
"fastPrivateGpsUpdates": "Mises à jour GPS privées rapides",
"movementThreshold": "Seuil de mouvement",
"fastGpsMovementThreshold": "Seuil de mouvement GPS rapide",
"fastGpsActiveuseInterval": "Intervalle d'utilisation active GPS rapide",
"activeuseUpdateInterval": "Intervalle de mise à jour en utilisation active",
"repeatNearbyTraffic": "Répéter le trafic proche",
"relayThroughRepeatersAcrossTheMesh": "Relayer via les répéteurs du réseau",
"nearbyOnlyWithoutRepeaterFlooding": "Proximité seulement, sans inondation",
"multihop": "Multi-saut",
"createProfile": "Créer un profil",
"renameProfile": "Renommer le profil",
"newProfile": "Nouveau profil",
"manageProfiles": "Gérer les profils",
"enableProfilesToStartManagingThem": "Activez les profils pour commencer à les gérer.",
"openMessage": "Ouvrir le message",
"jumpToTheRelatedSarMessage": "Aller au message SAR associé",
"removeSarMarker": "Supprimer le marqueur SAR",
"pleaseSelectADestinationToSendSarMarker": "Sélectionnez une destination pour envoyer le marqueur",
"sarMarkerBroadcastToPublicChannel": "Marqueur SAR diffusé sur le canal public",
"sarMarkerSentToRoom": "Marqueur SAR envoyé à la salle",
"loadFromGallery": "Charger depuis la galerie",
"replaceImage": "Remplacer l'image",
"selectFromGallery": "Sélectionner depuis la galerie",
"team": "Équipe",
"found": "Trouvé",
"staging": "Zone de regroupement",
"object": "Objet",
"quiet": "Calme",
"moderate": "Modéré",
"busy": "Occupé",
"spectrumScanReturnedNoCandidateFrequencies": "Le scan spectral n'a trouvé aucune fréquence candidate",
"searchMessages": "Rechercher des messages",
"sendImageFromGallery": "Envoyer une image de la galerie",
"takePhoto": "Prendre une photo",
"dmOnly": "Message direct uniquement",
"allMessages": "Tous les messages",
"sendToPublicChannel": "Envoyer au canal public ?",
"selectMarkerTypeAndDestination": "Sélectionnez le type de marqueur et la destination",
"noDestinationsAvailableLabel": "Aucune destination disponible",
"image": "Image",
"format": "Format",
"dimensions": "Dimensions",
"segments": "Segments",
"transfers": "Transferts",
"downloadedBy": "Téléchargé par",
"saveDiscoverySettings": "Enregistrer les paramètres de découverte",
"savePublicInfo": "Enregistrer les infos publiques",
"saveRadioSettings": "Enregistrer les paramètres radio",
"savePath": "Enregistrer le chemin",
"wipeDeviceData": "Effacer les données de l'appareil",
"wipeDevice": "Effacer l'appareil",
"destructiveDeviceActions": "Actions destructives de l'appareil.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Choisissez un préréglage ou ajustez les paramètres radio.",
"chooseTheNameAndLocationThisDeviceShares": "Choisissez le nom et l'emplacement partagés par cet appareil.",
"availableSpaceOnThisDevice": "Espace disponible sur cet appareil.",
"used": "Utilisé",
"total": "Total",
"renameValue": "Renommer la valeur",
"customizeFields": "Personnaliser les champs",
"livePreview": "Aperçu en direct",
"refreshSchedule": "Planification d'actualisation",
"noResponse": "Pas de réponse",
"refreshing": "Actualisation",
"unavailable": "Indisponible",
"pickARelayOrNodeToWatchInSensors": "Choisissez un relais ou nœud à surveiller.",
"publicKeyLabel": "Clé publique",
"alreadyInContacts": "Déjà dans les contacts",
"connectToADeviceBeforeAddingContacts": "Connectez-vous à un appareil avant d'ajouter des contacts",
"fromContacts": "Des contacts",
"onlineOnly": "En ligne uniquement",
"inBoth": "Dans les deux",
"source": "Source",
"manualRouteEdit": "Édition manuelle de route",
"observedMeshRoute": "Route mesh observée",
"allMessagesCleared": "Tous les messages effacés",
"onlineTraceDatabaseCleared": "Base de données de traces effacée",
"packetLogsCleared": "Journaux de paquets effacés",
"hexDataCopiedToClipboard": "Données hex copiées dans le presse-papiers",
"developerModeEnabled": "Mode développeur activé",
"developerModeDisabled": "Mode développeur désactivé",
"clipboardIsEmpty": "Le presse-papiers est vide",
"contactImported": "Contact importé",
"contactLinkCopiedToClipboard": "Lien du contact copié dans le presse-papiers",
"failedToExportContact": "Échec de l'exportation du contact",
"noLogsToExport": "Aucun journal à exporter",
"exportAsCsv": "Exporter en CSV",
"exportAsText": "Exporter en texte",
"receivedRfc3339": "Reçu (RFC3339)",
"buildTime": "Heure de build",
"downloadUrlNotAvailable": "URL de téléchargement non disponible",
"cannotOpenDownloadUrl": "Impossible d'ouvrir l'URL de téléchargement",
"updateCheckIsOnlyAvailableOnAndroid": "La vérification des mises à jour n'est disponible que sur Android",
"youAreRunningTheLatestVersion": "Vous utilisez la dernière version",
"updateAvailableButDownloadUrlNotFound": "Mise à jour disponible mais URL de téléchargement introuvable",
"startTictactoe": "Démarrer Tic-Tac-Toe",
"tictactoeUnavailable": "Tic-Tac-Toe indisponible",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: adversaire inconnu",
"tictactoeWaitingForStart": "Tic-Tac-Toe: en attente du début",
"acceptsShareLinks": "Accepte les liens partagés",
"supportsRawHex": "Supporte le hex brut",
"clipboardfriendly": "Compatible avec le presse-papiers",
"captured": "Capturé",
"size": "Taille",
"noCustomChannelsToClear": "Aucun canal personnalisé à effacer.",
"noDeviceContactsToClear": "Aucun contact de l'appareil à effacer."
}

View File

@@ -563,5 +563,252 @@
"contactFailedToClearRoute": "Brisanje rute nije uspjelo: {error}",
"contactRouteCleared": "Ruta obrisana",
"contactRouteSet": "Ruta postavljena: {route}",
"contactFailedToSetRoute": "Postavljanje rute nije uspjelo: {error}"
"contactFailedToSetRoute": "Postavljanje rute nije uspjelo: {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "ACK istek vremena",
"opcode": "Opkod",
"payload": "Sadržaj",
"hops": "Skokovi",
"hashSize": "Veličina hash-a",
"pathBytes": "Bajtovi putanje",
"selectedPath": "Odabrana putanja",
"estimatedTx": "Procijenjeno slanje",
"senderToReceipt": "Od pošiljatelja do primitka",
"receivedCopies": "Primljene kopije",
"retryCause": "Razlog ponovnog pokušaja",
"retryMode": "Način ponovnog pokušaja",
"retryResult": "Rezultat ponovnog pokušaja",
"lastRetry": "Zadnji pokušaj",
"rxPackets": "RX paketi",
"mesh": "Mesh",
"rate": "Brzina",
"window": "Prozor",
"posttxDelay": "Kašnjenje nakon slanja",
"bandpass": "Pojasni filtar",
"bandpassFilterVoice": "Pojasni filtar glasa",
"frequency": "Frekvencija",
"australia": "Australija",
"australiaNarrow": "Australija (usko)",
"australiaQld": "Australija: QLD",
"australiaSaWa": "Australija: SA, WA",
"newZealand": "Novi Zeland",
"newZealandNarrow": "Novi Zeland (usko)",
"switzerland": "Švicarska",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "Češka (usko)",
"eu433mhzLongRange": "EU 433MHz (veliki domet)",
"euukDeprecated": "EU/UK (zastarjelo)",
"euukNarrow": "EU/UK (usko)",
"usacanadaRecommended": "SAD/Kanada (preporučeno)",
"vietnamDeprecated": "Vijetnam (zastarjelo)",
"vietnamNarrow": "Vijetnam (usko)",
"active": "Aktivno",
"addContact": "Dodaj kontakt",
"all": "Sve",
"autoResolve": "Automatski razriješi",
"clearAllLabel": "Očisti sve",
"clearRelays": "Očisti posrednike",
"clearFilters": "Očisti filtre",
"clearRoute": "Očisti putanju",
"clearMessages": "Očisti poruke",
"clearScale": "Očisti mjerilo",
"clearDiscoveries": "Očisti otkrića",
"clearOnlineTraceDatabase": "Očisti bazu mrežnih tragova",
"clearAllChannels": "Očisti sve kanale",
"clearAllContacts": "Očisti sve kontakte",
"clearChannels": "Očisti kanale",
"clearContacts": "Očisti kontakte",
"clearPathOnMaxRetry": "Očisti putanju kod maks. pokušaja",
"create": "Stvori",
"custom": "Prilagođeno",
"defaultValue": "Zadano",
"duplicate": "Dupliciraj",
"editName": "Uredi ime",
"open": "Otvori",
"paste": "Zalijepi",
"preview": "Pregled",
"remove": "Ukloni",
"rename": "Preimenuj",
"resolveAll": "Razriješi sve",
"send": "Pošalji",
"sendAnyway": "Svejedno pošalji",
"share": "Dijeli",
"shareContact": "Dijeli kontakt",
"trace": "Praćenje",
"use": "Koristi",
"useSelectedFrequency": "Koristi odabranu frekvenciju",
"discovery": "Otkrivanje",
"discoverRepeaters": "Otkrij posrednike",
"discoverSensors": "Otkrij senzore",
"repeaterDiscoverySent": "Otkrivanje posrednika poslano",
"sensorDiscoverySent": "Otkrivanje senzora poslano",
"clearedPendingDiscoveries": "Očišćena čekajuća otkrića.",
"autoDiscovery": "Automatsko otkrivanje",
"enableAutomaticAdding": "Omogući automatsko dodavanje",
"autoaddRepeaters": "Automatski dodaj posrednike",
"autoaddRoomServers": "Automatski dodaj poslužitelje soba",
"autoaddSensors": "Automatski dodaj senzore",
"autoaddUsers": "Automatski dodaj korisnike",
"overwriteOldestWhenFull": "Prepiši najstarije kad je puno",
"storage": "Pohrana",
"dangerZone": "Opasna zona",
"profiles": "Profili",
"favourites": "Favoriti",
"sensors": "Senzori",
"others": "Ostali",
"gpsModule": "GPS modul",
"liveTraffic": "Promet uživo",
"repeatersMap": "Karta posrednika",
"spectrumScan": "Spektralno skeniranje",
"blePacketLogs": "BLE zapisi paketa",
"onlineTraceDatabase": "Baza mrežnih tragova",
"routePathByteSize": "Veličina putanje u bajtima",
"messageNotifications": "Obavijesti o porukama",
"sarAlerts": "SAR upozorenja",
"discoveryNotifications": "Obavijesti o otkrivanju",
"updateNotifications": "Obavijesti o ažuriranju",
"muteWhileAppIsOpen": "Utišaj dok je aplikacija otvorena",
"disableContacts": "Onemogući kontakte",
"enableSensorsTab": "Omogući karticu Senzori",
"enableProfiles": "Omogući profile",
"autoRouteRotation": "Automatsko rotiranje putanje",
"nearestRepeaterFallback": "Najbliži posrednik kao rezerva",
"deleteAllStoredMessageHistory": "Izbriši svu pohranjenu povijest poruka",
"messageFontSize": "Veličina fonta poruka",
"rotateMapWithHeading": "Rotiraj kartu prema smjeru",
"showMapDebugInfo": "Prikaži debug info karte",
"openMapInFullscreen": "Otvori kartu na cijelom zaslonu",
"showSarMarkersLabel": "Prikaži SAR oznake",
"displaySarMarkersOnTheMainMap": "Prikaži SAR oznake na glavnoj karti",
"showAllContactTrailsLabel": "Prikaži sve tragove kontakata",
"hideRepeatersOnMap": "Sakrij posrednike na karti",
"setMapScale": "Postavi mjerilo karte",
"customMapScaleSaved": "Prilagođeno mjerilo karte spremljeno",
"voiceBitrate": "Bitna brzina glasa",
"voiceCompressor": "Kompresor glasa",
"balancesQuietAndLoudSpeechLevels": "Izravnava tihe i glasne razine govora",
"voiceLimiter": "Ograničivač glasa",
"preventsClippingPeaksBeforeEncoding": "Sprječava rezanje vrhova prije kodiranja",
"micAutoGain": "Automatsko pojačanje mikrofona",
"letsTheRecorderAdjustInputLevel": "Dopušta snimaču prilagodbu razine ulaza",
"echoCancellation": "Poništavanje odjeka",
"noiseSuppression": "Potiskivanje šuma",
"trimSilenceInVoiceMessages": "Izreži tišinu u glasovnim porukama",
"compressor": "Kompresor",
"limiter": "Ograničivač",
"autoGain": "Automatsko pojačanje",
"echoCancel": "Odjek",
"noiseSuppress": "Šum",
"silenceTrim": "Tišina",
"maxImageSize": "Maksimalna veličina slike",
"imageCompression": "Kompresija slike",
"grayscale": "Sivine",
"ultraMode": "Ultra način",
"fastPrivateGpsUpdates": "Brza privatna GPS ažuriranja",
"movementThreshold": "Prag kretanja",
"fastGpsMovementThreshold": "Prag kretanja za brzi GPS",
"fastGpsActiveuseInterval": "Interval aktivnog korištenja brzog GPS-a",
"activeuseUpdateInterval": "Interval ažuriranja pri aktivnom korištenju",
"repeatNearbyTraffic": "Ponovi obližnji promet",
"relayThroughRepeatersAcrossTheMesh": "Proslijedi kroz posrednike kroz mrežu",
"nearbyOnlyWithoutRepeaterFlooding": "Samo obližnji, bez preplavljivanja posrednika",
"multihop": "Višeskočno",
"createProfile": "Stvori profil",
"renameProfile": "Preimenuj profil",
"newProfile": "Novi profil",
"manageProfiles": "Upravljaj profilima",
"enableProfilesToStartManagingThem": "Omogućite profile da biste ih počeli upravljati.",
"openMessage": "Otvori poruku",
"jumpToTheRelatedSarMessage": "Skoči na povezanu SAR poruku",
"removeSarMarker": "Ukloni SAR oznaku",
"pleaseSelectADestinationToSendSarMarker": "Odaberite odredište za slanje SAR oznake",
"sarMarkerBroadcastToPublicChannel": "SAR oznaka emitirana na javni kanal",
"sarMarkerSentToRoom": "SAR oznaka poslana u sobu",
"loadFromGallery": "Učitaj iz galerije",
"replaceImage": "Zamijeni sliku",
"selectFromGallery": "Odaberi iz galerije",
"team": "Tim",
"found": "Pronađeno",
"staging": "Sabirno mjesto",
"object": "Objekt",
"quiet": "Tiho",
"moderate": "Umjereno",
"busy": "Zauzeto",
"spectrumScanReturnedNoCandidateFrequencies": "Spektralno skeniranje nije pronašlo prikladne frekvencije",
"searchMessages": "Pretraži poruke",
"sendImageFromGallery": "Pošalji sliku iz galerije",
"takePhoto": "Snimi fotografiju",
"dmOnly": "Samo izravno",
"allMessages": "Sve poruke",
"sendToPublicChannel": "Pošalji na javni kanal?",
"selectMarkerTypeAndDestination": "Odaberite vrstu oznake i odredište",
"noDestinationsAvailableLabel": "Nema dostupnih odredišta",
"image": "Slika",
"format": "Format",
"dimensions": "Dimenzije",
"segments": "Segmenti",
"transfers": "Prijenosi",
"downloadedBy": "Preuzeo",
"saveDiscoverySettings": "Spremi postavke otkrivanja",
"savePublicInfo": "Spremi javne podatke",
"saveRadioSettings": "Spremi radijske postavke",
"savePath": "Spremi putanju",
"wipeDeviceData": "Obriši podatke uređaja",
"wipeDevice": "Obriši uređaj",
"destructiveDeviceActions": "Destruktivne radnje na uređaju.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Odaberite predefiniranu postavku ili fino podesite radijske postavke.",
"chooseTheNameAndLocationThisDeviceShares": "Odaberite ime i lokaciju koju ovaj uređaj dijeli.",
"availableSpaceOnThisDevice": "Raspoloživi prostor na ovom uređaju.",
"used": "Korišteno",
"total": "Ukupno",
"renameValue": "Preimenuj vrijednost",
"customizeFields": "Prilagodi polja",
"livePreview": "Pregled uživo",
"refreshSchedule": "Raspored osvježavanja",
"noResponse": "Nema odgovora",
"refreshing": "Osvježavam",
"unavailable": "Nedostupno",
"pickARelayOrNodeToWatchInSensors": "Odaberite posrednika ili čvor za praćenje.",
"publicKeyLabel": "Javni ključ",
"alreadyInContacts": "Već u kontaktima",
"connectToADeviceBeforeAddingContacts": "Povežite se s uređajem prije dodavanja kontakata",
"fromContacts": "Iz kontakata",
"onlineOnly": "Samo povezani",
"inBoth": "U oba",
"source": "Izvor",
"manualRouteEdit": "Ručno uređivanje putanje",
"observedMeshRoute": "Opažena mrežna putanja",
"allMessagesCleared": "Sve poruke očišćene",
"onlineTraceDatabaseCleared": "Baza mrežnih tragova očišćena",
"packetLogsCleared": "Zapisi paketa očišćeni",
"hexDataCopiedToClipboard": "Hex podaci kopirani u međuspremnik",
"developerModeEnabled": "Razvojni način omogućen",
"developerModeDisabled": "Razvojni način onemogućen",
"clipboardIsEmpty": "Međuspremnik je prazan",
"contactImported": "Kontakt uvezen",
"contactLinkCopiedToClipboard": "Poveznica na kontakt kopirana u međuspremnik",
"failedToExportContact": "Izvoz kontakta nije uspio",
"noLogsToExport": "Nema zapisa za izvoz",
"exportAsCsv": "Izvezi kao CSV",
"exportAsText": "Izvezi kao tekst",
"receivedRfc3339": "Primljeno (RFC3339)",
"buildTime": "Vrijeme gradnje",
"downloadUrlNotAvailable": "URL za preuzimanje nije dostupan",
"cannotOpenDownloadUrl": "Ne mogu otvoriti URL za preuzimanje",
"updateCheckIsOnlyAvailableOnAndroid": "Provjera ažuriranja dostupna je samo na Androidu",
"youAreRunningTheLatestVersion": "Koristite najnoviju verziju",
"updateAvailableButDownloadUrlNotFound": "Ažuriranje dostupno, ali URL za preuzimanje nije pronađen",
"startTictactoe": "Pokreni Tic-Tac-Toe",
"tictactoeUnavailable": "Tic-Tac-Toe nije dostupan",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: protivnik nepoznat",
"tictactoeWaitingForStart": "Tic-Tac-Toe: čekanje na početak",
"acceptsShareLinks": "Prihvaća dijeljene veze",
"supportsRawHex": "Podržava sirovi hex",
"clipboardfriendly": "Pogodno za međuspremnik",
"captured": "Zabilježeno",
"size": "Veličina",
"noCustomChannelsToClear": "Nema prilagođenih kanala za brisanje.",
"noDeviceContactsToClear": "Nema kontakata uređaja za brisanje."
}

View File

@@ -1641,5 +1641,252 @@
"contactFailedToClearRoute": "Impossibile cancellare il percorso: {error}",
"contactRouteCleared": "Percorso cancellato",
"contactRouteSet": "Percorso impostato: {route}",
"contactFailedToSetRoute": "Impossibile impostare il percorso: {error}"
"contactFailedToSetRoute": "Impossibile impostare il percorso: {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "Timeout ACK",
"opcode": "Opcode",
"payload": "Payload",
"hops": "Hop",
"hashSize": "Dimensione hash",
"pathBytes": "Byte percorso",
"selectedPath": "Percorso selezionato",
"estimatedTx": "Trasmissione stimata",
"senderToReceipt": "Mittente a ricevuta",
"receivedCopies": "Copie ricevute",
"retryCause": "Causa del ritentativo",
"retryMode": "Modalità ritentativo",
"retryResult": "Risultato ritentativo",
"lastRetry": "Ultimo ritentativo",
"rxPackets": "Pacchetti RX",
"mesh": "Mesh",
"rate": "Velocità",
"window": "Finestra",
"posttxDelay": "Ritardo post-tx",
"bandpass": "Passa-banda",
"bandpassFilterVoice": "Filtro passa-banda voce",
"frequency": "Frequenza",
"australia": "Australia",
"australiaNarrow": "Australia (Stretto)",
"australiaQld": "Australia: QLD",
"australiaSaWa": "Australia: SA, WA",
"newZealand": "Nuova Zelanda",
"newZealandNarrow": "Nuova Zelanda (Stretto)",
"switzerland": "Svizzera",
"portugal433": "Portogallo 433",
"portugal868": "Portogallo 868",
"czechRepublicNarrow": "Repubblica Ceca (Stretto)",
"eu433mhzLongRange": "UE 433MHz (Lungo Raggio)",
"euukDeprecated": "UE/UK (Deprecato)",
"euukNarrow": "UE/UK (Stretto)",
"usacanadaRecommended": "USA/Canada (Consigliato)",
"vietnamDeprecated": "Vietnam (Deprecato)",
"vietnamNarrow": "Vietnam (Stretto)",
"active": "Attivo",
"addContact": "Aggiungi contatto",
"all": "Tutto",
"autoResolve": "Risolvi automaticamente",
"clearAllLabel": "Cancella tutto",
"clearRelays": "Cancella relay",
"clearFilters": "Cancella filtri",
"clearRoute": "Cancella percorso",
"clearMessages": "Cancella messaggi",
"clearScale": "Cancella scala",
"clearDiscoveries": "Cancella scoperte",
"clearOnlineTraceDatabase": "Cancella database tracce",
"clearAllChannels": "Cancella tutti i canali",
"clearAllContacts": "Cancella tutti i contatti",
"clearChannels": "Cancella canali",
"clearContacts": "Cancella contatti",
"clearPathOnMaxRetry": "Cancella percorso al max ritentativo",
"create": "Crea",
"custom": "Personalizzato",
"defaultValue": "Predefinito",
"duplicate": "Duplica",
"editName": "Modifica nome",
"open": "Apri",
"paste": "Incolla",
"preview": "Anteprima",
"remove": "Rimuovi",
"rename": "Rinomina",
"resolveAll": "Risolvi tutto",
"send": "Invia",
"sendAnyway": "Invia comunque",
"share": "Condividi",
"shareContact": "Condividi contatto",
"trace": "Traccia",
"use": "Usa",
"useSelectedFrequency": "Usa frequenza selezionata",
"discovery": "Scoperta",
"discoverRepeaters": "Scopri ripetitori",
"discoverSensors": "Scopri sensori",
"repeaterDiscoverySent": "Scoperta ripetitori inviata",
"sensorDiscoverySent": "Scoperta sensori inviata",
"clearedPendingDiscoveries": "Scoperte in attesa cancellate.",
"autoDiscovery": "Scoperta automatica",
"enableAutomaticAdding": "Abilita aggiunta automatica",
"autoaddRepeaters": "Aggiungi ripetitori automaticamente",
"autoaddRoomServers": "Aggiungi server stanze automaticamente",
"autoaddSensors": "Aggiungi sensori automaticamente",
"autoaddUsers": "Aggiungi utenti automaticamente",
"overwriteOldestWhenFull": "Sovrascrivi i più vecchi quando pieno",
"storage": "Archiviazione",
"dangerZone": "Zona pericolosa",
"profiles": "Profili",
"favourites": "Preferiti",
"sensors": "Sensori",
"others": "Altri",
"gpsModule": "Modulo GPS",
"liveTraffic": "Traffico in tempo reale",
"repeatersMap": "Mappa ripetitori",
"spectrumScan": "Scansione spettro",
"blePacketLogs": "Log pacchetti BLE",
"onlineTraceDatabase": "Database tracce",
"routePathByteSize": "Dimensione percorso in byte",
"messageNotifications": "Notifiche messaggi",
"sarAlerts": "Avvisi SAR",
"discoveryNotifications": "Notifiche scoperte",
"updateNotifications": "Notifiche aggiornamenti",
"muteWhileAppIsOpen": "Silenzia con l'app aperta",
"disableContacts": "Disabilita contatti",
"enableSensorsTab": "Abilita scheda Sensori",
"enableProfiles": "Abilita profili",
"autoRouteRotation": "Rotazione automatica percorso",
"nearestRepeaterFallback": "Ripetitore più vicino come fallback",
"deleteAllStoredMessageHistory": "Elimina tutta la cronologia messaggi",
"messageFontSize": "Dimensione carattere messaggi",
"rotateMapWithHeading": "Ruota mappa con direzione",
"showMapDebugInfo": "Mostra info debug mappa",
"openMapInFullscreen": "Apri mappa a schermo intero",
"showSarMarkersLabel": "Mostra marcatori SAR",
"displaySarMarkersOnTheMainMap": "Mostra marcatori SAR sulla mappa principale",
"showAllContactTrailsLabel": "Mostra tutti i percorsi contatti",
"hideRepeatersOnMap": "Nascondi ripetitori sulla mappa",
"setMapScale": "Imposta scala mappa",
"customMapScaleSaved": "Scala mappa personalizzata salvata",
"voiceBitrate": "Bitrate voce",
"voiceCompressor": "Compressore voce",
"balancesQuietAndLoudSpeechLevels": "Bilancia i livelli di voce bassi e alti",
"voiceLimiter": "Limitatore voce",
"preventsClippingPeaksBeforeEncoding": "Previene il clipping prima della codifica",
"micAutoGain": "Guadagno automatico microfono",
"letsTheRecorderAdjustInputLevel": "Permette al registratore di regolare il livello",
"echoCancellation": "Cancellazione eco",
"noiseSuppression": "Soppressione rumore",
"trimSilenceInVoiceMessages": "Taglia silenzio nei messaggi vocali",
"compressor": "Compressore",
"limiter": "Limitatore",
"autoGain": "Guadagno automatico",
"echoCancel": "Eco",
"noiseSuppress": "Rumore",
"silenceTrim": "Silenzio",
"maxImageSize": "Dimensione massima immagine",
"imageCompression": "Compressione immagine",
"grayscale": "Scala di grigi",
"ultraMode": "Modalità ultra",
"fastPrivateGpsUpdates": "Aggiornamenti GPS privati veloci",
"movementThreshold": "Soglia di movimento",
"fastGpsMovementThreshold": "Soglia movimento GPS veloce",
"fastGpsActiveuseInterval": "Intervallo uso attivo GPS veloce",
"activeuseUpdateInterval": "Intervallo aggiornamento uso attivo",
"repeatNearbyTraffic": "Ripeti traffico vicino",
"relayThroughRepeatersAcrossTheMesh": "Inoltra tramite ripetitori nella rete",
"nearbyOnlyWithoutRepeaterFlooding": "Solo vicini, senza flood dei ripetitori",
"multihop": "Multi-hop",
"createProfile": "Crea profilo",
"renameProfile": "Rinomina profilo",
"newProfile": "Nuovo profilo",
"manageProfiles": "Gestisci profili",
"enableProfilesToStartManagingThem": "Abilita i profili per iniziare a gestirli.",
"openMessage": "Apri messaggio",
"jumpToTheRelatedSarMessage": "Vai al messaggio SAR correlato",
"removeSarMarker": "Rimuovi marcatore SAR",
"pleaseSelectADestinationToSendSarMarker": "Seleziona una destinazione per inviare il marcatore SAR",
"sarMarkerBroadcastToPublicChannel": "Marcatore SAR trasmesso al canale pubblico",
"sarMarkerSentToRoom": "Marcatore SAR inviato alla stanza",
"loadFromGallery": "Carica dalla galleria",
"replaceImage": "Sostituisci immagine",
"selectFromGallery": "Seleziona dalla galleria",
"team": "Squadra",
"found": "Trovato",
"staging": "Area di raccolta",
"object": "Oggetto",
"quiet": "Silenzioso",
"moderate": "Moderato",
"busy": "Occupato",
"spectrumScanReturnedNoCandidateFrequencies": "La scansione spettro non ha trovato frequenze candidate",
"searchMessages": "Cerca messaggi",
"sendImageFromGallery": "Invia immagine dalla galleria",
"takePhoto": "Scatta foto",
"dmOnly": "Solo messaggio diretto",
"allMessages": "Tutti i messaggi",
"sendToPublicChannel": "Inviare al canale pubblico?",
"selectMarkerTypeAndDestination": "Seleziona tipo marcatore e destinazione",
"noDestinationsAvailableLabel": "Nessuna destinazione disponibile",
"image": "Immagine",
"format": "Formato",
"dimensions": "Dimensioni",
"segments": "Segmenti",
"transfers": "Trasferimenti",
"downloadedBy": "Scaricato da",
"saveDiscoverySettings": "Salva impostazioni scoperta",
"savePublicInfo": "Salva info pubbliche",
"saveRadioSettings": "Salva impostazioni radio",
"savePath": "Salva percorso",
"wipeDeviceData": "Cancella dati dispositivo",
"wipeDevice": "Cancella dispositivo",
"destructiveDeviceActions": "Azioni distruttive del dispositivo.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Scegli un preset o personalizza le impostazioni radio.",
"chooseTheNameAndLocationThisDeviceShares": "Scegli il nome e la posizione condivisi da questo dispositivo.",
"availableSpaceOnThisDevice": "Spazio disponibile su questo dispositivo.",
"used": "Usato",
"total": "Totale",
"renameValue": "Rinomina valore",
"customizeFields": "Personalizza campi",
"livePreview": "Anteprima dal vivo",
"refreshSchedule": "Programma aggiornamento",
"noResponse": "Nessuna risposta",
"refreshing": "Aggiornamento",
"unavailable": "Non disponibile",
"pickARelayOrNodeToWatchInSensors": "Scegli un relay o nodo da monitorare.",
"publicKeyLabel": "Chiave pubblica",
"alreadyInContacts": "Già nei contatti",
"connectToADeviceBeforeAddingContacts": "Connettiti a un dispositivo prima di aggiungere contatti",
"fromContacts": "Dai contatti",
"onlineOnly": "Solo online",
"inBoth": "In entrambi",
"source": "Origine",
"manualRouteEdit": "Modifica manuale percorso",
"observedMeshRoute": "Percorso mesh osservato",
"allMessagesCleared": "Tutti i messaggi cancellati",
"onlineTraceDatabaseCleared": "Database tracce cancellata",
"packetLogsCleared": "Log pacchetti cancellati",
"hexDataCopiedToClipboard": "Dati hex copiati negli appunti",
"developerModeEnabled": "Modalità sviluppatore attivata",
"developerModeDisabled": "Modalità sviluppatore disattivata",
"clipboardIsEmpty": "Gli appunti sono vuoti",
"contactImported": "Contatto importato",
"contactLinkCopiedToClipboard": "Link contatto copiato negli appunti",
"failedToExportContact": "Esportazione contatto fallita",
"noLogsToExport": "Nessun log da esportare",
"exportAsCsv": "Esporta come CSV",
"exportAsText": "Esporta come testo",
"receivedRfc3339": "Ricevuto (RFC3339)",
"buildTime": "Ora build",
"downloadUrlNotAvailable": "URL di download non disponibile",
"cannotOpenDownloadUrl": "Impossibile aprire URL di download",
"updateCheckIsOnlyAvailableOnAndroid": "Il controllo aggiornamenti è disponibile solo su Android",
"youAreRunningTheLatestVersion": "Stai utilizzando l'ultima versione",
"updateAvailableButDownloadUrlNotFound": "Aggiornamento disponibile ma URL di download non trovato",
"startTictactoe": "Avvia Tic-Tac-Toe",
"tictactoeUnavailable": "Tic-Tac-Toe non disponibile",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: avversario sconosciuto",
"tictactoeWaitingForStart": "Tic-Tac-Toe: in attesa dell'inizio",
"acceptsShareLinks": "Accetta link condivisi",
"supportsRawHex": "Supporta hex grezzo",
"clipboardfriendly": "Compatibile con appunti",
"captured": "Catturato",
"size": "Dimensione",
"noCustomChannelsToClear": "Nessun canale personalizzato da cancellare.",
"noDeviceContactsToClear": "Nessun contatto dispositivo da cancellare."
}

File diff suppressed because it is too large Load Diff

View File

@@ -1929,4 +1929,773 @@ class AppLocalizationsDe extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Pfad konnte nicht gesetzt werden: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'ACK-Zeitüberschreitung';
@override
String get opcode => 'Opcode';
@override
String get payload => 'Nutzlast';
@override
String get hops => 'Hops';
@override
String get hashSize => 'Hash-Größe';
@override
String get pathBytes => 'Pfad-Bytes';
@override
String get selectedPath => 'Ausgewählter Pfad';
@override
String get estimatedTx => 'Geschätzte Sendung';
@override
String get senderToReceipt => 'Sender bis Empfang';
@override
String get receivedCopies => 'Empfangene Kopien';
@override
String get retryCause => 'Wiederholungsursache';
@override
String get retryMode => 'Wiederholungsmodus';
@override
String get retryResult => 'Wiederholungsergebnis';
@override
String get lastRetry => 'Letzter Versuch';
@override
String get rxPackets => 'RX-Pakete';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Rate';
@override
String get window => 'Fenster';
@override
String get posttxDelay => 'Sendeverzögerung';
@override
String get bandpass => 'Bandpass';
@override
String get bandpassFilterVoice => 'Bandpassfilter Sprache';
@override
String get frequency => 'Frequenz';
@override
String get australia => 'Australien';
@override
String get australiaNarrow => 'Australien (Schmal)';
@override
String get australiaQld => 'Australien: QLD';
@override
String get australiaSaWa => 'Australien: SA, WA';
@override
String get newZealand => 'Neuseeland';
@override
String get newZealandNarrow => 'Neuseeland (Schmal)';
@override
String get switzerland => 'Schweiz';
@override
String get portugal433 => 'Portugal 433';
@override
String get portugal868 => 'Portugal 868';
@override
String get czechRepublicNarrow => 'Tschechien (Schmal)';
@override
String get eu433mhzLongRange => 'EU 433MHz (Langstrecke)';
@override
String get euukDeprecated => 'EU/UK (Veraltet)';
@override
String get euukNarrow => 'EU/UK (Schmal)';
@override
String get usacanadaRecommended => 'USA/Kanada (Empfohlen)';
@override
String get vietnamDeprecated => 'Vietnam (Veraltet)';
@override
String get vietnamNarrow => 'Vietnam (Schmal)';
@override
String get active => 'Aktiv';
@override
String get addContact => 'Kontakt hinzufügen';
@override
String get all => 'Alle';
@override
String get autoResolve => 'Automatisch auflösen';
@override
String get clearAllLabel => 'Alle löschen';
@override
String get clearRelays => 'Relais löschen';
@override
String get clearFilters => 'Filter löschen';
@override
String get clearRoute => 'Route löschen';
@override
String get clearMessages => 'Nachrichten löschen';
@override
String get clearScale => 'Maßstab löschen';
@override
String get clearDiscoveries => 'Entdeckungen löschen';
@override
String get clearOnlineTraceDatabase => 'Online-Trace-Datenbank löschen';
@override
String get clearAllChannels => 'Alle Kanäle löschen';
@override
String get clearAllContacts => 'Alle Kontakte löschen';
@override
String get clearChannels => 'Kanäle löschen';
@override
String get clearContacts => 'Kontakte löschen';
@override
String get clearPathOnMaxRetry => 'Pfad bei max. Wiederholung löschen';
@override
String get create => 'Erstellen';
@override
String get custom => 'Benutzerdefiniert';
@override
String get defaultValue => 'Standard';
@override
String get duplicate => 'Duplizieren';
@override
String get editName => 'Name bearbeiten';
@override
String get open => 'Öffnen';
@override
String get paste => 'Einfügen';
@override
String get preview => 'Vorschau';
@override
String get remove => 'Entfernen';
@override
String get rename => 'Umbenennen';
@override
String get resolveAll => 'Alle auflösen';
@override
String get send => 'Senden';
@override
String get sendAnyway => 'Trotzdem senden';
@override
String get share => 'Teilen';
@override
String get shareContact => 'Kontakt teilen';
@override
String get trace => 'Trace';
@override
String get use => 'Verwenden';
@override
String get useSelectedFrequency => 'Ausgewählte Frequenz verwenden';
@override
String get discovery => 'Erkennung';
@override
String get discoverRepeaters => 'Repeater entdecken';
@override
String get discoverSensors => 'Sensoren entdecken';
@override
String get repeaterDiscoverySent => 'Repeater-Erkennung gesendet';
@override
String get sensorDiscoverySent => 'Sensor-Erkennung gesendet';
@override
String get clearedPendingDiscoveries => 'Ausstehende Entdeckungen gelöscht.';
@override
String get autoDiscovery => 'Automatische Erkennung';
@override
String get enableAutomaticAdding => 'Automatisches Hinzufügen aktivieren';
@override
String get autoaddRepeaters => 'Repeater automatisch hinzufügen';
@override
String get autoaddRoomServers => 'Raumserver automatisch hinzufügen';
@override
String get autoaddSensors => 'Sensoren automatisch hinzufügen';
@override
String get autoaddUsers => 'Benutzer automatisch hinzufügen';
@override
String get overwriteOldestWhenFull =>
'Älteste bei vollem Speicher überschreiben';
@override
String get storage => 'Speicher';
@override
String get dangerZone => 'Gefahrenbereich';
@override
String get profiles => 'Profile';
@override
String get favourites => 'Favoriten';
@override
String get sensors => 'Sensoren';
@override
String get others => 'Andere';
@override
String get gpsModule => 'GPS-Modul';
@override
String get liveTraffic => 'Live-Verkehr';
@override
String get repeatersMap => 'Repeater-Karte';
@override
String get spectrumScan => 'Spektrum-Scan';
@override
String get blePacketLogs => 'BLE-Paketprotokolle';
@override
String get onlineTraceDatabase => 'Online-Trace-Datenbank';
@override
String get routePathByteSize => 'Pfad-Bytegröße';
@override
String get messageNotifications => 'Nachrichtenbenachrichtigungen';
@override
String get sarAlerts => 'SAR-Alarme';
@override
String get discoveryNotifications => 'Erkennungsbenachrichtigungen';
@override
String get updateNotifications => 'Updatebenachrichtigungen';
@override
String get muteWhileAppIsOpen => 'Stumm bei geöffneter App';
@override
String get disableContacts => 'Kontakte deaktivieren';
@override
String get enableSensorsTab => 'Sensoren-Tab aktivieren';
@override
String get enableProfiles => 'Profile aktivieren';
@override
String get autoRouteRotation => 'Automatische Routenrotation';
@override
String get nearestRepeaterFallback => 'Nächster Repeater als Fallback';
@override
String get deleteAllStoredMessageHistory =>
'Gesamten Nachrichtenverlauf löschen';
@override
String get messageFontSize => 'Nachrichtenschriftgröße';
@override
String get rotateMapWithHeading => 'Karte mit Richtung drehen';
@override
String get showMapDebugInfo => 'Karten-Debug-Info anzeigen';
@override
String get openMapInFullscreen => 'Karte im Vollbild öffnen';
@override
String get showSarMarkersLabel => 'SAR-Markierungen anzeigen';
@override
String get displaySarMarkersOnTheMainMap =>
'SAR-Markierungen auf der Hauptkarte anzeigen';
@override
String get showAllContactTrailsLabel => 'Alle Kontaktpfade anzeigen';
@override
String get hideRepeatersOnMap => 'Repeater auf Karte ausblenden';
@override
String get setMapScale => 'Kartenmaßstab einstellen';
@override
String get customMapScaleSaved =>
'Benutzerdefinierter Kartenmaßstab gespeichert';
@override
String get voiceBitrate => 'Sprach-Bitrate';
@override
String get voiceCompressor => 'Sprachkompressor';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Gleicht leise und laute Sprache aus';
@override
String get voiceLimiter => 'Sprach-Limiter';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Verhindert Clipping-Spitzen vor der Kodierung';
@override
String get micAutoGain => 'Mikrofon-Automatik';
@override
String get letsTheRecorderAdjustInputLevel =>
'Lässt den Rekorder den Eingangspegel anpassen';
@override
String get echoCancellation => 'Echounterdrückung';
@override
String get noiseSuppression => 'Rauschunterdrückung';
@override
String get trimSilenceInVoiceMessages =>
'Stille in Sprachnachrichten trimmen';
@override
String get compressor => 'Kompressor';
@override
String get limiter => 'Limiter';
@override
String get autoGain => 'Automatische Verstärkung';
@override
String get echoCancel => 'Echo';
@override
String get noiseSuppress => 'Rauschen';
@override
String get silenceTrim => 'Stille';
@override
String get maxImageSize => 'Maximale Bildgröße';
@override
String get imageCompression => 'Bildkomprimierung';
@override
String get grayscale => 'Graustufen';
@override
String get ultraMode => 'Ultra-Modus';
@override
String get fastPrivateGpsUpdates => 'Schnelle private GPS-Updates';
@override
String get movementThreshold => 'Bewegungsschwelle';
@override
String get fastGpsMovementThreshold => 'Schneller GPS-Bewegungsschwellenwert';
@override
String get fastGpsActiveuseInterval =>
'Schnelles GPS aktive Nutzung Intervall';
@override
String get activeuseUpdateInterval => 'Aktive Nutzung Updateintervall';
@override
String get repeatNearbyTraffic => 'Nahverkehr wiederholen';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Über Repeater im Mesh weiterleiten';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Nur in der Nähe, ohne Repeater-Flooding';
@override
String get multihop => 'Multi-Hop';
@override
String get createProfile => 'Profil erstellen';
@override
String get renameProfile => 'Profil umbenennen';
@override
String get newProfile => 'Neues Profil';
@override
String get manageProfiles => 'Profile verwalten';
@override
String get enableProfilesToStartManagingThem =>
'Aktivieren Sie Profile, um sie zu verwalten.';
@override
String get openMessage => 'Nachricht öffnen';
@override
String get jumpToTheRelatedSarMessage =>
'Zur zugehörigen SAR-Nachricht springen';
@override
String get removeSarMarker => 'SAR-Markierung entfernen';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Bitte wählen Sie ein Ziel zum Senden der SAR-Markierung';
@override
String get sarMarkerBroadcastToPublicChannel =>
'SAR-Markierung an öffentlichen Kanal gesendet';
@override
String get sarMarkerSentToRoom => 'SAR-Markierung an Raum gesendet';
@override
String get loadFromGallery => 'Aus Galerie laden';
@override
String get replaceImage => 'Bild ersetzen';
@override
String get selectFromGallery => 'Aus Galerie auswählen';
@override
String get team => 'Team';
@override
String get found => 'Gefunden';
@override
String get staging => 'Sammelpunkt';
@override
String get object => 'Objekt';
@override
String get quiet => 'Ruhig';
@override
String get moderate => 'Mäßig';
@override
String get busy => 'Beschäftigt';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'Spektrum-Scan hat keine Kandidatenfrequenzen gefunden';
@override
String get searchMessages => 'Nachrichten suchen';
@override
String get sendImageFromGallery => 'Bild aus Galerie senden';
@override
String get takePhoto => 'Foto aufnehmen';
@override
String get dmOnly => 'Nur Direktnachricht';
@override
String get allMessages => 'Alle Nachrichten';
@override
String get sendToPublicChannel => 'An öffentlichen Kanal senden?';
@override
String get selectMarkerTypeAndDestination =>
'Markierungstyp und Ziel auswählen';
@override
String get noDestinationsAvailableLabel => 'Keine Ziele verfügbar';
@override
String get image => 'Bild';
@override
String get format => 'Format';
@override
String get dimensions => 'Abmessungen';
@override
String get segments => 'Segmente';
@override
String get transfers => 'Übertragungen';
@override
String get downloadedBy => 'Heruntergeladen von';
@override
String get saveDiscoverySettings => 'Erkennungseinstellungen speichern';
@override
String get savePublicInfo => 'Öffentliche Info speichern';
@override
String get saveRadioSettings => 'Funkeinstellungen speichern';
@override
String get savePath => 'Pfad speichern';
@override
String get wipeDeviceData => 'Gerätedaten löschen';
@override
String get wipeDevice => 'Gerät löschen';
@override
String get destructiveDeviceActions => 'Destruktive Geräteaktionen.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Wählen Sie eine Voreinstellung oder passen Sie die Funkeinstellungen an.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Wählen Sie den Namen und Standort, den dieses Gerät teilt.';
@override
String get availableSpaceOnThisDevice =>
'Verfügbarer Speicherplatz auf diesem Gerät.';
@override
String get used => 'Belegt';
@override
String get total => 'Gesamt';
@override
String get renameValue => 'Wert umbenennen';
@override
String get customizeFields => 'Felder anpassen';
@override
String get livePreview => 'Live-Vorschau';
@override
String get refreshSchedule => 'Aktualisierungsplan';
@override
String get noResponse => 'Keine Antwort';
@override
String get refreshing => 'Wird aktualisiert';
@override
String get unavailable => 'Nicht verfügbar';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Wählen Sie einen Relay oder Knoten zur Beobachtung.';
@override
String get publicKeyLabel => 'Öffentlicher Schlüssel';
@override
String get alreadyInContacts => 'Bereits in Kontakten';
@override
String get connectToADeviceBeforeAddingContacts =>
'Verbinden Sie sich mit einem Gerät, bevor Sie Kontakte hinzufügen';
@override
String get fromContacts => 'Aus Kontakten';
@override
String get onlineOnly => 'Nur online';
@override
String get inBoth => 'In beiden';
@override
String get source => 'Quelle';
@override
String get manualRouteEdit => 'Manuelle Routenbearbeitung';
@override
String get observedMeshRoute => 'Beobachtete Mesh-Route';
@override
String get allMessagesCleared => 'Alle Nachrichten gelöscht';
@override
String get onlineTraceDatabaseCleared => 'Online-Trace-Datenbank gelöscht';
@override
String get packetLogsCleared => 'Paketprotokolle gelöscht';
@override
String get hexDataCopiedToClipboard =>
'Hex-Daten in die Zwischenablage kopiert';
@override
String get developerModeEnabled => 'Entwicklermodus aktiviert';
@override
String get developerModeDisabled => 'Entwicklermodus deaktiviert';
@override
String get clipboardIsEmpty => 'Zwischenablage ist leer';
@override
String get contactImported => 'Kontakt importiert';
@override
String get contactLinkCopiedToClipboard =>
'Kontaktlink in Zwischenablage kopiert';
@override
String get failedToExportContact => 'Kontaktexport fehlgeschlagen';
@override
String get noLogsToExport => 'Keine Protokolle zum Exportieren';
@override
String get exportAsCsv => 'Als CSV exportieren';
@override
String get exportAsText => 'Als Text exportieren';
@override
String get receivedRfc3339 => 'Empfangen (RFC3339)';
@override
String get buildTime => 'Build-Zeit';
@override
String get downloadUrlNotAvailable => 'Download-URL nicht verfügbar';
@override
String get cannotOpenDownloadUrl => 'Download-URL kann nicht geöffnet werden';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'Update-Prüfung nur auf Android verfügbar';
@override
String get youAreRunningTheLatestVersion =>
'Sie verwenden die neueste Version';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Update verfügbar, aber Download-URL nicht gefunden';
@override
String get startTictactoe => 'Tic-Tac-Toe starten';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe nicht verfügbar';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: Gegner unbekannt';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: Warten auf Start';
@override
String get acceptsShareLinks => 'Akzeptiert freigegebene Links';
@override
String get supportsRawHex => 'Unterstützt rohes Hex';
@override
String get clipboardfriendly => 'Zwischenablage-freundlich';
@override
String get captured => 'Erfasst';
@override
String get size => 'Größe';
@override
String get noCustomChannelsToClear =>
'Keine benutzerdefinierten Kanäle zum Löschen.';
@override
String get noDeviceContactsToClear => 'Keine Gerätekontakte zum Löschen.';
}

View File

@@ -1936,4 +1936,768 @@ class AppLocalizationsEl extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Αποτυχία ορισμού διαδρομής: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'Λήξη χρόνου ACK';
@override
String get opcode => 'Opcode';
@override
String get payload => 'Ωφέλιμο φορτίο';
@override
String get hops => 'Αναπηδήσεις';
@override
String get hashSize => 'Μέγεθος hash';
@override
String get pathBytes => 'Bytes διαδρομής';
@override
String get selectedPath => 'Επιλεγμένη διαδρομή';
@override
String get estimatedTx => 'Εκτιμώμενη εκπομπή';
@override
String get senderToReceipt => 'Αποστολέας σε απόδειξη';
@override
String get receivedCopies => 'Ληφθέντα αντίγραφα';
@override
String get retryCause => 'Αιτία επανάληψης';
@override
String get retryMode => 'Λειτουργία επανάληψης';
@override
String get retryResult => 'Αποτέλεσμα επανάληψης';
@override
String get lastRetry => 'Τελευταία επανάληψη';
@override
String get rxPackets => 'Πακέτα RX';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Ρυθμός';
@override
String get window => 'Παράθυρο';
@override
String get posttxDelay => 'Καθυστέρηση μετά-tx';
@override
String get bandpass => 'Ζωνοπερατό';
@override
String get bandpassFilterVoice => 'Ζωνοπερατό φίλτρο φωνής';
@override
String get frequency => 'Συχνότητα';
@override
String get australia => 'Αυστραλία';
@override
String get australiaNarrow => 'Αυστραλία (Στενό)';
@override
String get australiaQld => 'Αυστραλία: QLD';
@override
String get australiaSaWa => 'Αυστραλία: SA, WA';
@override
String get newZealand => 'Νέα Ζηλανδία';
@override
String get newZealandNarrow => 'Νέα Ζηλανδία (Στενό)';
@override
String get switzerland => 'Ελβετία';
@override
String get portugal433 => 'Πορτογαλία 433';
@override
String get portugal868 => 'Πορτογαλία 868';
@override
String get czechRepublicNarrow => 'Τσεχία (Στενό)';
@override
String get eu433mhzLongRange => 'ΕΕ 433MHz (Μεγάλη Εμβέλεια)';
@override
String get euukDeprecated => 'ΕΕ/ΗΒ (Παρωχημένο)';
@override
String get euukNarrow => 'ΕΕ/ΗΒ (Στενό)';
@override
String get usacanadaRecommended => 'ΗΠΑ/Καναδάς (Προτεινόμενο)';
@override
String get vietnamDeprecated => 'Βιετνάμ (Παρωχημένο)';
@override
String get vietnamNarrow => 'Βιετνάμ (Στενό)';
@override
String get active => 'Ενεργό';
@override
String get addContact => 'Προσθήκη επαφής';
@override
String get all => 'Όλα';
@override
String get autoResolve => 'Αυτόματη επίλυση';
@override
String get clearAllLabel => 'Εκκαθάριση όλων';
@override
String get clearRelays => 'Εκκαθάριση αναμεταδοτών';
@override
String get clearFilters => 'Εκκαθάριση φίλτρων';
@override
String get clearRoute => 'Εκκαθάριση διαδρομής';
@override
String get clearMessages => 'Εκκαθάριση μηνυμάτων';
@override
String get clearScale => 'Εκκαθάριση κλίμακας';
@override
String get clearDiscoveries => 'Εκκαθάριση ανακαλύψεων';
@override
String get clearOnlineTraceDatabase => 'Εκκαθάριση βάσης ιχνών';
@override
String get clearAllChannels => 'Εκκαθάριση όλων των καναλιών';
@override
String get clearAllContacts => 'Εκκαθάριση όλων των επαφών';
@override
String get clearChannels => 'Εκκαθάριση καναλιών';
@override
String get clearContacts => 'Εκκαθάριση επαφών';
@override
String get clearPathOnMaxRetry => 'Εκκαθάριση μονοπατιού στο μέγιστο';
@override
String get create => 'Δημιουργία';
@override
String get custom => 'Προσαρμοσμένο';
@override
String get defaultValue => 'Προεπιλογή';
@override
String get duplicate => 'Αντιγραφή';
@override
String get editName => 'Επεξεργασία ονόματος';
@override
String get open => 'Άνοιγμα';
@override
String get paste => 'Επικόλληση';
@override
String get preview => 'Προεπισκόπηση';
@override
String get remove => 'Αφαίρεση';
@override
String get rename => 'Μετονομασία';
@override
String get resolveAll => 'Επίλυση όλων';
@override
String get send => 'Αποστολή';
@override
String get sendAnyway => 'Αποστολή ούτως ή άλλως';
@override
String get share => 'Κοινοποίηση';
@override
String get shareContact => 'Κοινοποίηση επαφής';
@override
String get trace => 'Ίχνος';
@override
String get use => 'Χρήση';
@override
String get useSelectedFrequency => 'Χρήση επιλεγμένης συχνότητας';
@override
String get discovery => 'Ανακάλυψη';
@override
String get discoverRepeaters => 'Ανακάλυψη αναμεταδοτών';
@override
String get discoverSensors => 'Ανακάλυψη αισθητήρων';
@override
String get repeaterDiscoverySent => 'Αποστολή ανακάλυψης αναμεταδοτών';
@override
String get sensorDiscoverySent => 'Αποστολή ανακάλυψης αισθητήρων';
@override
String get clearedPendingDiscoveries => 'Εκκαθάριση εκκρεμών ανακαλύψεων.';
@override
String get autoDiscovery => 'Αυτόματη ανακάλυψη';
@override
String get enableAutomaticAdding => 'Ενεργοποίηση αυτόματης προσθήκης';
@override
String get autoaddRepeaters => 'Αυτόματη προσθήκη αναμεταδοτών';
@override
String get autoaddRoomServers => 'Αυτόματη προσθήκη διακομιστών δωματίου';
@override
String get autoaddSensors => 'Αυτόματη προσθήκη αισθητήρων';
@override
String get autoaddUsers => 'Αυτόματη προσθήκη χρηστών';
@override
String get overwriteOldestWhenFull =>
'Αντικατάσταση παλαιότερων όταν γεμίσει';
@override
String get storage => 'Αποθήκευση';
@override
String get dangerZone => 'Ζώνη κινδύνου';
@override
String get profiles => 'Προφίλ';
@override
String get favourites => 'Αγαπημένα';
@override
String get sensors => 'Αισθητήρες';
@override
String get others => 'Άλλοι';
@override
String get gpsModule => 'Μονάδα GPS';
@override
String get liveTraffic => 'Ζωντανή κίνηση';
@override
String get repeatersMap => 'Χάρτης αναμεταδοτών';
@override
String get spectrumScan => 'Σάρωση φάσματος';
@override
String get blePacketLogs => 'Αρχεία πακέτων BLE';
@override
String get onlineTraceDatabase => 'Βάση δεδομένων ιχνών';
@override
String get routePathByteSize => 'Μέγεθος διαδρομής σε bytes';
@override
String get messageNotifications => 'Ειδοποιήσεις μηνυμάτων';
@override
String get sarAlerts => 'Ειδοποιήσεις SAR';
@override
String get discoveryNotifications => 'Ειδοποιήσεις ανακάλυψης';
@override
String get updateNotifications => 'Ειδοποιήσεις ενημερώσεων';
@override
String get muteWhileAppIsOpen => 'Σίγαση με ανοιχτή εφαρμογή';
@override
String get disableContacts => 'Απενεργοποίηση επαφών';
@override
String get enableSensorsTab => 'Ενεργοποίηση καρτέλας Αισθητήρων';
@override
String get enableProfiles => 'Ενεργοποίηση προφίλ';
@override
String get autoRouteRotation => 'Αυτόματη εναλλαγή διαδρομής';
@override
String get nearestRepeaterFallback => 'Πλησιέστερος αναμεταδότης ως εφεδρεία';
@override
String get deleteAllStoredMessageHistory => 'Διαγραφή όλου του ιστορικού';
@override
String get messageFontSize => 'Μέγεθος γραμματοσειράς μηνυμάτων';
@override
String get rotateMapWithHeading => 'Περιστροφή χάρτη με κατεύθυνση';
@override
String get showMapDebugInfo => 'Εμφάνιση πληροφοριών αποσφαλμάτωσης';
@override
String get openMapInFullscreen => 'Άνοιγμα χάρτη σε πλήρη οθόνη';
@override
String get showSarMarkersLabel => 'Εμφάνιση δεικτών SAR';
@override
String get displaySarMarkersOnTheMainMap =>
'Εμφάνιση δεικτών SAR στον κύριο χάρτη';
@override
String get showAllContactTrailsLabel => 'Εμφάνιση όλων των ιχνών επαφών';
@override
String get hideRepeatersOnMap => 'Απόκρυψη αναμεταδοτών στον χάρτη';
@override
String get setMapScale => 'Ρύθμιση κλίμακας χάρτη';
@override
String get customMapScaleSaved => 'Προσαρμοσμένη κλίμακα χάρτη αποθηκεύτηκε';
@override
String get voiceBitrate => 'Ρυθμός bit φωνής';
@override
String get voiceCompressor => 'Συμπιεστής φωνής';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Εξισορρόπηση χαμηλών και δυνατών επιπέδων';
@override
String get voiceLimiter => 'Περιοριστής φωνής';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Αποτρέπει κοπή κορυφών πριν την κωδικοποίηση';
@override
String get micAutoGain => 'Αυτόματη ενίσχυση μικροφώνου';
@override
String get letsTheRecorderAdjustInputLevel =>
'Αφήνει τη συσκευή να ρυθμίσει την ένταση';
@override
String get echoCancellation => 'Ακύρωση ηχούς';
@override
String get noiseSuppression => 'Καταστολή θορύβου';
@override
String get trimSilenceInVoiceMessages =>
'Περικοπή σιωπής σε φωνητικά μηνύματα';
@override
String get compressor => 'Συμπιεστής';
@override
String get limiter => 'Περιοριστής';
@override
String get autoGain => 'Αυτόματη ενίσχυση';
@override
String get echoCancel => 'Ηχώ';
@override
String get noiseSuppress => 'Θόρυβος';
@override
String get silenceTrim => 'Σιωπή';
@override
String get maxImageSize => 'Μέγιστο μέγεθος εικόνας';
@override
String get imageCompression => 'Συμπίεση εικόνας';
@override
String get grayscale => 'Κλίμακα του γκρι';
@override
String get ultraMode => 'Λειτουργία ultra';
@override
String get fastPrivateGpsUpdates => 'Γρήγορες ιδιωτικές ενημερώσεις GPS';
@override
String get movementThreshold => 'Κατώφλι κίνησης';
@override
String get fastGpsMovementThreshold => 'Κατώφλι κίνησης γρήγορου GPS';
@override
String get fastGpsActiveuseInterval => 'Διάστημα ενεργής χρήσης γρήγορου GPS';
@override
String get activeuseUpdateInterval => 'Διάστημα ενημέρωσης ενεργής χρήσης';
@override
String get repeatNearbyTraffic => 'Επανάληψη κοντινής κίνησης';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Αναμετάδοση μέσω αναμεταδοτών στο δίκτυο';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Μόνο κοντά, χωρίς πλημμυρισμό';
@override
String get multihop => 'Πολλαπλών αναπηδήσεων';
@override
String get createProfile => 'Δημιουργία προφίλ';
@override
String get renameProfile => 'Μετονομασία προφίλ';
@override
String get newProfile => 'Νέο προφίλ';
@override
String get manageProfiles => 'Διαχείριση προφίλ';
@override
String get enableProfilesToStartManagingThem =>
'Ενεργοποιήστε τα προφίλ για να τα διαχειριστείτε.';
@override
String get openMessage => 'Άνοιγμα μηνύματος';
@override
String get jumpToTheRelatedSarMessage => 'Μετάβαση στο σχετικό μήνυμα SAR';
@override
String get removeSarMarker => 'Αφαίρεση δείκτη SAR';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Επιλέξτε προορισμό για αποστολή δείκτη SAR';
@override
String get sarMarkerBroadcastToPublicChannel =>
'Δείκτης SAR εκπεμπόμενος στο δημόσιο κανάλι';
@override
String get sarMarkerSentToRoom => 'Δείκτης SAR εστάλη στο δωμάτιο';
@override
String get loadFromGallery => 'Φόρτωση από τη γκαλερί';
@override
String get replaceImage => 'Αντικατάσταση εικόνας';
@override
String get selectFromGallery => 'Επιλογή από τη γκαλερί';
@override
String get team => 'Ομάδα';
@override
String get found => 'Βρέθηκε';
@override
String get staging => 'Χώρος συγκέντρωσης';
@override
String get object => 'Αντικείμενο';
@override
String get quiet => 'Ήσυχο';
@override
String get moderate => 'Μέτριο';
@override
String get busy => 'Απασχολημένο';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'Η σάρωση φάσματος δεν βρήκε υποψήφιες συχνότητες';
@override
String get searchMessages => 'Αναζήτηση μηνυμάτων';
@override
String get sendImageFromGallery => 'Αποστολή εικόνας από τη γκαλερί';
@override
String get takePhoto => 'Λήψη φωτογραφίας';
@override
String get dmOnly => 'Μόνο απευθείας';
@override
String get allMessages => 'Όλα τα μηνύματα';
@override
String get sendToPublicChannel => 'Αποστολή στο δημόσιο κανάλι;';
@override
String get selectMarkerTypeAndDestination =>
'Επιλέξτε τύπο δείκτη και προορισμό';
@override
String get noDestinationsAvailableLabel =>
'Δεν υπάρχουν διαθέσιμοι προορισμοί';
@override
String get image => 'Εικόνα';
@override
String get format => 'Μορφή';
@override
String get dimensions => 'Διαστάσεις';
@override
String get segments => 'Τμήματα';
@override
String get transfers => 'Μεταφορές';
@override
String get downloadedBy => 'Λήφθηκε από';
@override
String get saveDiscoverySettings => 'Αποθήκευση ρυθμίσεων ανακάλυψης';
@override
String get savePublicInfo => 'Αποθήκευση δημόσιων πληροφοριών';
@override
String get saveRadioSettings => 'Αποθήκευση ρυθμίσεων ραδιοφώνου';
@override
String get savePath => 'Αποθήκευση διαδρομής';
@override
String get wipeDeviceData => 'Διαγραφή δεδομένων συσκευής';
@override
String get wipeDevice => 'Διαγραφή συσκευής';
@override
String get destructiveDeviceActions => 'Καταστροφικές ενέργειες συσκευής.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Επιλέξτε μια προεπιλογή ή ρυθμίστε τις ρυθμίσεις ραδιοφώνου.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Επιλέξτε το όνομα και την τοποθεσία που μοιράζεται αυτή η συσκευή.';
@override
String get availableSpaceOnThisDevice =>
'Διαθέσιμος χώρος σε αυτή τη συσκευή.';
@override
String get used => 'Χρησιμοποιήθηκε';
@override
String get total => 'Σύνολο';
@override
String get renameValue => 'Μετονομασία τιμής';
@override
String get customizeFields => 'Προσαρμογή πεδίων';
@override
String get livePreview => 'Ζωντανή προεπισκόπηση';
@override
String get refreshSchedule => 'Χρονοδιάγραμμα ανανέωσης';
@override
String get noResponse => 'Χωρίς απάντηση';
@override
String get refreshing => 'Ανανέωση';
@override
String get unavailable => 'Μη διαθέσιμο';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Επιλέξτε ένα relay ή κόμβο για παρακολούθηση.';
@override
String get publicKeyLabel => 'Δημόσιο κλειδί';
@override
String get alreadyInContacts => 'Ήδη στις επαφές';
@override
String get connectToADeviceBeforeAddingContacts =>
'Συνδεθείτε σε μια συσκευή πριν προσθέσετε επαφές';
@override
String get fromContacts => 'Από τις επαφές';
@override
String get onlineOnly => 'Μόνο συνδεδεμένοι';
@override
String get inBoth => 'Σε αμφότερα';
@override
String get source => 'Πηγή';
@override
String get manualRouteEdit => 'Χειροκίνητη επεξεργασία διαδρομής';
@override
String get observedMeshRoute => 'Παρατηρούμενη διαδρομή δικτύου';
@override
String get allMessagesCleared => 'Όλα τα μηνύματα διαγράφηκαν';
@override
String get onlineTraceDatabaseCleared => 'Βάση δεδομένων ιχνών εκκαθαρίστηκε';
@override
String get packetLogsCleared => 'Τα αρχεία πακέτων εκκαθαρίστηκαν';
@override
String get hexDataCopiedToClipboard => 'Τα δεδομένα hex αντιγράφηκαν';
@override
String get developerModeEnabled => 'Λειτουργία προγραμματιστή ενεργοποιήθηκε';
@override
String get developerModeDisabled =>
'Λειτουργία προγραμματιστή απενεργοποιήθηκε';
@override
String get clipboardIsEmpty => 'Το πρόχειρο είναι κενό';
@override
String get contactImported => 'Επαφή εισήχθη';
@override
String get contactLinkCopiedToClipboard => 'Ο σύνδεσμος επαφής αντιγράφηκε';
@override
String get failedToExportContact => 'Αποτυχία εξαγωγής επαφής';
@override
String get noLogsToExport => 'Δεν υπάρχουν αρχεία για εξαγωγή';
@override
String get exportAsCsv => 'Εξαγωγή ως CSV';
@override
String get exportAsText => 'Εξαγωγή ως κείμενο';
@override
String get receivedRfc3339 => 'Ελήφθη (RFC3339)';
@override
String get buildTime => 'Ώρα κατασκευής';
@override
String get downloadUrlNotAvailable => 'Η URL λήψης δεν είναι διαθέσιμη';
@override
String get cannotOpenDownloadUrl => 'Αδυναμία ανοίγματος URL λήψης';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'Ο έλεγχος ενημερώσεων είναι διαθέσιμος μόνο σε Android';
@override
String get youAreRunningTheLatestVersion =>
'Χρησιμοποιείτε την πιο πρόσφατη έκδοση';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Διαθέσιμη ενημέρωση αλλά η URL λήψης δεν βρέθηκε';
@override
String get startTictactoe => 'Εκκίνηση Tic-Tac-Toe';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe μη διαθέσιμο';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: άγνωστος αντίπαλος';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: αναμονή για εκκίνηση';
@override
String get acceptsShareLinks => 'Αποδέχεται κοινόχρηστους συνδέσμους';
@override
String get supportsRawHex => 'Υποστηρίζει ακατέργαστο hex';
@override
String get clipboardfriendly => 'Φιλικό προς πρόχειρο';
@override
String get captured => 'Καταγράφηκε';
@override
String get size => 'Μέγεθος';
@override
String get noCustomChannelsToClear => 'Δεν υπάρχουν προσαρμοσμένα κανάλια.';
@override
String get noDeviceContactsToClear => 'Δεν υπάρχουν επαφές συσκευής.';
}

View File

@@ -1902,4 +1902,764 @@ class AppLocalizationsEn extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Failed to set route: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'ACK timeout';
@override
String get opcode => 'Opcode';
@override
String get payload => 'Payload';
@override
String get hops => 'Hops';
@override
String get hashSize => 'Hash size';
@override
String get pathBytes => 'Path bytes';
@override
String get selectedPath => 'Selected path';
@override
String get estimatedTx => 'Estimated tx';
@override
String get senderToReceipt => 'Sender to receipt';
@override
String get receivedCopies => 'Received copies';
@override
String get retryCause => 'Retry cause';
@override
String get retryMode => 'Retry mode';
@override
String get retryResult => 'Retry result';
@override
String get lastRetry => 'Last retry';
@override
String get rxPackets => 'RX packets';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Rate';
@override
String get window => 'Window';
@override
String get posttxDelay => 'Post-tx delay';
@override
String get bandpass => 'Band-pass';
@override
String get bandpassFilterVoice => 'Band-pass filter voice';
@override
String get frequency => 'Frequency';
@override
String get australia => 'Australia';
@override
String get australiaNarrow => 'Australia (Narrow)';
@override
String get australiaQld => 'Australia: QLD';
@override
String get australiaSaWa => 'Australia: SA, WA';
@override
String get newZealand => 'New Zealand';
@override
String get newZealandNarrow => 'New Zealand (Narrow)';
@override
String get switzerland => 'Switzerland';
@override
String get portugal433 => 'Portugal 433';
@override
String get portugal868 => 'Portugal 868';
@override
String get czechRepublicNarrow => 'Czech Republic (Narrow)';
@override
String get eu433mhzLongRange => 'EU 433MHz (Long Range)';
@override
String get euukDeprecated => 'EU/UK (Deprecated)';
@override
String get euukNarrow => 'EU/UK (Narrow)';
@override
String get usacanadaRecommended => 'USA/Canada (Recommended)';
@override
String get vietnamDeprecated => 'Vietnam (Deprecated)';
@override
String get vietnamNarrow => 'Vietnam (Narrow)';
@override
String get active => 'Active';
@override
String get addContact => 'Add Contact';
@override
String get all => 'All';
@override
String get autoResolve => 'Auto resolve';
@override
String get clearAllLabel => 'Clear all';
@override
String get clearRelays => 'Clear relays';
@override
String get clearFilters => 'Clear filters';
@override
String get clearRoute => 'Clear Route';
@override
String get clearMessages => 'Clear Messages';
@override
String get clearScale => 'Clear scale';
@override
String get clearDiscoveries => 'Clear discoveries';
@override
String get clearOnlineTraceDatabase => 'Clear online trace database';
@override
String get clearAllChannels => 'Clear all channels';
@override
String get clearAllContacts => 'Clear all contacts';
@override
String get clearChannels => 'Clear channels';
@override
String get clearContacts => 'Clear contacts';
@override
String get clearPathOnMaxRetry => 'Clear path on max retry';
@override
String get create => 'Create';
@override
String get custom => 'Custom';
@override
String get defaultValue => 'Default';
@override
String get duplicate => 'Duplicate';
@override
String get editName => 'Edit name';
@override
String get open => 'Open';
@override
String get paste => 'Paste';
@override
String get preview => 'Preview';
@override
String get remove => 'Remove';
@override
String get rename => 'Rename';
@override
String get resolveAll => 'Resolve all';
@override
String get send => 'Send';
@override
String get sendAnyway => 'Send anyway';
@override
String get share => 'Share';
@override
String get shareContact => 'Share Contact';
@override
String get trace => 'Trace';
@override
String get use => 'Use';
@override
String get useSelectedFrequency => 'Use selected frequency';
@override
String get discovery => 'Discovery';
@override
String get discoverRepeaters => 'Discover repeaters';
@override
String get discoverSensors => 'Discover sensors';
@override
String get repeaterDiscoverySent => 'Repeater discovery sent';
@override
String get sensorDiscoverySent => 'Sensor discovery sent';
@override
String get clearedPendingDiscoveries => 'Cleared pending discoveries.';
@override
String get autoDiscovery => 'Auto discovery';
@override
String get enableAutomaticAdding => 'Enable automatic adding';
@override
String get autoaddRepeaters => 'Auto-add repeaters';
@override
String get autoaddRoomServers => 'Auto-add room servers';
@override
String get autoaddSensors => 'Auto-add sensors';
@override
String get autoaddUsers => 'Auto-add users';
@override
String get overwriteOldestWhenFull => 'Overwrite oldest when full';
@override
String get storage => 'Storage';
@override
String get dangerZone => 'Danger zone';
@override
String get profiles => 'Profiles';
@override
String get favourites => 'Favourites';
@override
String get sensors => 'Sensors';
@override
String get others => 'Others';
@override
String get gpsModule => 'GPS Module';
@override
String get liveTraffic => 'Live Traffic';
@override
String get repeatersMap => 'Repeaters Map';
@override
String get spectrumScan => 'Spectrum Scan';
@override
String get blePacketLogs => 'BLE Packet Logs';
@override
String get onlineTraceDatabase => 'Online trace database';
@override
String get routePathByteSize => 'Route path byte size';
@override
String get messageNotifications => 'Message notifications';
@override
String get sarAlerts => 'SAR alerts';
@override
String get discoveryNotifications => 'Discovery notifications';
@override
String get updateNotifications => 'Update notifications';
@override
String get muteWhileAppIsOpen => 'Mute while app is open';
@override
String get disableContacts => 'Disable Contacts';
@override
String get enableSensorsTab => 'Enable Sensors tab';
@override
String get enableProfiles => 'Enable Profiles';
@override
String get autoRouteRotation => 'Auto route rotation';
@override
String get nearestRepeaterFallback => 'Nearest repeater fallback';
@override
String get deleteAllStoredMessageHistory =>
'Delete all stored message history';
@override
String get messageFontSize => 'Message font size';
@override
String get rotateMapWithHeading => 'Rotate map with heading';
@override
String get showMapDebugInfo => 'Show map debug info';
@override
String get openMapInFullscreen => 'Open map in fullscreen';
@override
String get showSarMarkersLabel => 'Show SAR markers';
@override
String get displaySarMarkersOnTheMainMap =>
'Display SAR markers on the main map';
@override
String get showAllContactTrailsLabel => 'Show all contact trails';
@override
String get hideRepeatersOnMap => 'Hide repeaters on map';
@override
String get setMapScale => 'Set map scale';
@override
String get customMapScaleSaved => 'Custom map scale saved';
@override
String get voiceBitrate => 'Voice bitrate';
@override
String get voiceCompressor => 'Voice compressor';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Balances quiet and loud speech levels';
@override
String get voiceLimiter => 'Voice limiter';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Prevents clipping peaks before encoding';
@override
String get micAutoGain => 'Mic auto gain';
@override
String get letsTheRecorderAdjustInputLevel =>
'Lets the recorder adjust input level';
@override
String get echoCancellation => 'Echo cancellation';
@override
String get noiseSuppression => 'Noise suppression';
@override
String get trimSilenceInVoiceMessages => 'Trim silence in voice messages';
@override
String get compressor => 'Compressor';
@override
String get limiter => 'Limiter';
@override
String get autoGain => 'Auto gain';
@override
String get echoCancel => 'Echo cancel';
@override
String get noiseSuppress => 'Noise suppress';
@override
String get silenceTrim => 'Silence trim';
@override
String get maxImageSize => 'Max image size';
@override
String get imageCompression => 'Image compression';
@override
String get grayscale => 'Grayscale';
@override
String get ultraMode => 'Ultra mode';
@override
String get fastPrivateGpsUpdates => 'Fast private GPS updates';
@override
String get movementThreshold => 'Movement threshold';
@override
String get fastGpsMovementThreshold => 'Fast GPS movement threshold';
@override
String get fastGpsActiveuseInterval => 'Fast GPS active-use interval';
@override
String get activeuseUpdateInterval => 'Active-use update interval';
@override
String get repeatNearbyTraffic => 'Repeat nearby traffic';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Relay through repeaters across the mesh';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Nearby only, without repeater flooding';
@override
String get multihop => 'Multi-hop';
@override
String get createProfile => 'Create Profile';
@override
String get renameProfile => 'Rename Profile';
@override
String get newProfile => 'New Profile';
@override
String get manageProfiles => 'Manage profiles';
@override
String get enableProfilesToStartManagingThem =>
'Enable profiles to start managing them.';
@override
String get openMessage => 'Open message';
@override
String get jumpToTheRelatedSarMessage => 'Jump to the related SAR message';
@override
String get removeSarMarker => 'Remove SAR marker';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Please select a destination to send SAR marker';
@override
String get sarMarkerBroadcastToPublicChannel =>
'SAR marker broadcast to public channel';
@override
String get sarMarkerSentToRoom => 'SAR marker sent to room';
@override
String get loadFromGallery => 'Load from gallery';
@override
String get replaceImage => 'Replace image';
@override
String get selectFromGallery => 'Select from gallery';
@override
String get team => 'Team';
@override
String get found => 'Found';
@override
String get staging => 'Staging';
@override
String get object => 'Object';
@override
String get quiet => 'Quiet';
@override
String get moderate => 'Moderate';
@override
String get busy => 'Busy';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'Spectrum scan returned no candidate frequencies';
@override
String get searchMessages => 'Search messages';
@override
String get sendImageFromGallery => 'Send image from gallery';
@override
String get takePhoto => 'Take photo';
@override
String get dmOnly => 'DM only';
@override
String get allMessages => 'All messages';
@override
String get sendToPublicChannel => 'Send to Public Channel?';
@override
String get selectMarkerTypeAndDestination =>
'Select marker type and destination';
@override
String get noDestinationsAvailableLabel => 'No destinations available';
@override
String get image => 'Image';
@override
String get format => 'Format';
@override
String get dimensions => 'Dimensions';
@override
String get segments => 'Segments';
@override
String get transfers => 'Transfers';
@override
String get downloadedBy => 'Downloaded by';
@override
String get saveDiscoverySettings => 'Save discovery settings';
@override
String get savePublicInfo => 'Save public info';
@override
String get saveRadioSettings => 'Save radio settings';
@override
String get savePath => 'Save Path';
@override
String get wipeDeviceData => 'Wipe device data';
@override
String get wipeDevice => 'Wipe device';
@override
String get destructiveDeviceActions => 'Destructive device actions.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Choose a preset or fine-tune custom radio settings.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Choose the name and location this device shares.';
@override
String get availableSpaceOnThisDevice => 'Available space on this device.';
@override
String get used => 'Used';
@override
String get total => 'Total';
@override
String get renameValue => 'Rename value';
@override
String get customizeFields => 'Customize fields';
@override
String get livePreview => 'Live preview';
@override
String get refreshSchedule => 'Refresh schedule';
@override
String get noResponse => 'No response';
@override
String get refreshing => 'Refreshing';
@override
String get unavailable => 'Unavailable';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Pick a relay or node to watch in Sensors.';
@override
String get publicKeyLabel => 'Public key';
@override
String get alreadyInContacts => 'Already in contacts';
@override
String get connectToADeviceBeforeAddingContacts =>
'Connect to a device before adding contacts';
@override
String get fromContacts => 'From contacts';
@override
String get onlineOnly => 'Online only';
@override
String get inBoth => 'In both';
@override
String get source => 'Source';
@override
String get manualRouteEdit => 'Manual route edit';
@override
String get observedMeshRoute => 'Observed mesh route';
@override
String get allMessagesCleared => 'All messages cleared';
@override
String get onlineTraceDatabaseCleared => 'Online trace database cleared';
@override
String get packetLogsCleared => 'Packet logs cleared';
@override
String get hexDataCopiedToClipboard => 'Hex data copied to clipboard';
@override
String get developerModeEnabled => 'Developer mode enabled';
@override
String get developerModeDisabled => 'Developer mode disabled';
@override
String get clipboardIsEmpty => 'Clipboard is empty';
@override
String get contactImported => 'Contact imported';
@override
String get contactLinkCopiedToClipboard => 'Contact link copied to clipboard';
@override
String get failedToExportContact => 'Failed to export contact';
@override
String get noLogsToExport => 'No logs to export';
@override
String get exportAsCsv => 'Export as CSV';
@override
String get exportAsText => 'Export as Text';
@override
String get receivedRfc3339 => 'Received (RFC3339)';
@override
String get buildTime => 'Build Time';
@override
String get downloadUrlNotAvailable => 'Download URL not available';
@override
String get cannotOpenDownloadUrl => 'Cannot open download URL';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'Update check is only available on Android';
@override
String get youAreRunningTheLatestVersion =>
'You are running the latest version';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Update available but download URL not found';
@override
String get startTictactoe => 'Start Tic-Tac-Toe';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe unavailable';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: opponent unknown';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: waiting for start';
@override
String get acceptsShareLinks => 'Accepts share links';
@override
String get supportsRawHex => 'Supports raw hex';
@override
String get clipboardfriendly => 'Clipboard-friendly';
@override
String get captured => 'Captured';
@override
String get size => 'Size';
@override
String get noCustomChannelsToClear => 'No custom channels to clear.';
@override
String get noDeviceContactsToClear => 'No device contacts to clear.';
}

View File

@@ -1932,4 +1932,774 @@ class AppLocalizationsEs extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'No se pudo establecer la ruta: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'Tiempo de espera ACK';
@override
String get opcode => 'Código de operación';
@override
String get payload => 'Carga útil';
@override
String get hops => 'Saltos';
@override
String get hashSize => 'Tamaño del hash';
@override
String get pathBytes => 'Bytes de ruta';
@override
String get selectedPath => 'Ruta seleccionada';
@override
String get estimatedTx => 'Transmisión estimada';
@override
String get senderToReceipt => 'Del remitente al recibo';
@override
String get receivedCopies => 'Copias recibidas';
@override
String get retryCause => 'Causa del reintento';
@override
String get retryMode => 'Modo de reintento';
@override
String get retryResult => 'Resultado del reintento';
@override
String get lastRetry => 'Último reintento';
@override
String get rxPackets => 'Paquetes RX';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Velocidad';
@override
String get window => 'Ventana';
@override
String get posttxDelay => 'Retardo post-tx';
@override
String get bandpass => 'Paso de banda';
@override
String get bandpassFilterVoice => 'Filtro de paso de banda de voz';
@override
String get frequency => 'Frecuencia';
@override
String get australia => 'Australia';
@override
String get australiaNarrow => 'Australia (Estrecho)';
@override
String get australiaQld => 'Australia: QLD';
@override
String get australiaSaWa => 'Australia: SA, WA';
@override
String get newZealand => 'Nueva Zelanda';
@override
String get newZealandNarrow => 'Nueva Zelanda (Estrecho)';
@override
String get switzerland => 'Suiza';
@override
String get portugal433 => 'Portugal 433';
@override
String get portugal868 => 'Portugal 868';
@override
String get czechRepublicNarrow => 'República Checa (Estrecho)';
@override
String get eu433mhzLongRange => 'EU 433MHz (Largo Alcance)';
@override
String get euukDeprecated => 'UE/RU (Obsoleto)';
@override
String get euukNarrow => 'UE/RU (Estrecho)';
@override
String get usacanadaRecommended => 'EE.UU./Canadá (Recomendado)';
@override
String get vietnamDeprecated => 'Vietnam (Obsoleto)';
@override
String get vietnamNarrow => 'Vietnam (Estrecho)';
@override
String get active => 'Activo';
@override
String get addContact => 'Añadir contacto';
@override
String get all => 'Todo';
@override
String get autoResolve => 'Resolver automáticamente';
@override
String get clearAllLabel => 'Borrar todo';
@override
String get clearRelays => 'Borrar repetidores';
@override
String get clearFilters => 'Borrar filtros';
@override
String get clearRoute => 'Borrar ruta';
@override
String get clearMessages => 'Borrar mensajes';
@override
String get clearScale => 'Borrar escala';
@override
String get clearDiscoveries => 'Borrar descubrimientos';
@override
String get clearOnlineTraceDatabase => 'Borrar base de datos de trazas';
@override
String get clearAllChannels => 'Borrar todos los canales';
@override
String get clearAllContacts => 'Borrar todos los contactos';
@override
String get clearChannels => 'Borrar canales';
@override
String get clearContacts => 'Borrar contactos';
@override
String get clearPathOnMaxRetry => 'Borrar ruta en máximo reintento';
@override
String get create => 'Crear';
@override
String get custom => 'Personalizado';
@override
String get defaultValue => 'Predeterminado';
@override
String get duplicate => 'Duplicar';
@override
String get editName => 'Editar nombre';
@override
String get open => 'Abrir';
@override
String get paste => 'Pegar';
@override
String get preview => 'Vista previa';
@override
String get remove => 'Eliminar';
@override
String get rename => 'Renombrar';
@override
String get resolveAll => 'Resolver todo';
@override
String get send => 'Enviar';
@override
String get sendAnyway => 'Enviar de todos modos';
@override
String get share => 'Compartir';
@override
String get shareContact => 'Compartir contacto';
@override
String get trace => 'Traza';
@override
String get use => 'Usar';
@override
String get useSelectedFrequency => 'Usar frecuencia seleccionada';
@override
String get discovery => 'Descubrimiento';
@override
String get discoverRepeaters => 'Descubrir repetidores';
@override
String get discoverSensors => 'Descubrir sensores';
@override
String get repeaterDiscoverySent => 'Descubrimiento de repetidores enviado';
@override
String get sensorDiscoverySent => 'Descubrimiento de sensores enviado';
@override
String get clearedPendingDiscoveries =>
'Descubrimientos pendientes borrados.';
@override
String get autoDiscovery => 'Descubrimiento automático';
@override
String get enableAutomaticAdding => 'Habilitar adición automática';
@override
String get autoaddRepeaters => 'Añadir repetidores automáticamente';
@override
String get autoaddRoomServers => 'Añadir servidores de sala automáticamente';
@override
String get autoaddSensors => 'Añadir sensores automáticamente';
@override
String get autoaddUsers => 'Añadir usuarios automáticamente';
@override
String get overwriteOldestWhenFull =>
'Sobrescribir más antiguos cuando esté lleno';
@override
String get storage => 'Almacenamiento';
@override
String get dangerZone => 'Zona de peligro';
@override
String get profiles => 'Perfiles';
@override
String get favourites => 'Favoritos';
@override
String get sensors => 'Sensores';
@override
String get others => 'Otros';
@override
String get gpsModule => 'Módulo GPS';
@override
String get liveTraffic => 'Tráfico en vivo';
@override
String get repeatersMap => 'Mapa de repetidores';
@override
String get spectrumScan => 'Escaneo de espectro';
@override
String get blePacketLogs => 'Registros de paquetes BLE';
@override
String get onlineTraceDatabase => 'Base de datos de trazas';
@override
String get routePathByteSize => 'Tamaño en bytes de la ruta';
@override
String get messageNotifications => 'Notificaciones de mensajes';
@override
String get sarAlerts => 'Alertas SAR';
@override
String get discoveryNotifications => 'Notificaciones de descubrimiento';
@override
String get updateNotifications => 'Notificaciones de actualización';
@override
String get muteWhileAppIsOpen => 'Silenciar con la app abierta';
@override
String get disableContacts => 'Desactivar contactos';
@override
String get enableSensorsTab => 'Habilitar pestaña Sensores';
@override
String get enableProfiles => 'Habilitar perfiles';
@override
String get autoRouteRotation => 'Rotación automática de ruta';
@override
String get nearestRepeaterFallback => 'Repetidor más cercano como reserva';
@override
String get deleteAllStoredMessageHistory =>
'Eliminar todo el historial de mensajes';
@override
String get messageFontSize => 'Tamaño de fuente de mensajes';
@override
String get rotateMapWithHeading => 'Rotar mapa con rumbo';
@override
String get showMapDebugInfo => 'Mostrar info de depuración del mapa';
@override
String get openMapInFullscreen => 'Abrir mapa en pantalla completa';
@override
String get showSarMarkersLabel => 'Mostrar marcadores SAR';
@override
String get displaySarMarkersOnTheMainMap =>
'Mostrar marcadores SAR en el mapa principal';
@override
String get showAllContactTrailsLabel =>
'Mostrar todos los rastros de contactos';
@override
String get hideRepeatersOnMap => 'Ocultar repetidores en el mapa';
@override
String get setMapScale => 'Establecer escala del mapa';
@override
String get customMapScaleSaved => 'Escala de mapa personalizada guardada';
@override
String get voiceBitrate => 'Tasa de bits de voz';
@override
String get voiceCompressor => 'Compresor de voz';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Equilibra niveles de habla suaves y altos';
@override
String get voiceLimiter => 'Limitador de voz';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Previene recorte de picos antes de codificar';
@override
String get micAutoGain => 'Ganancia automática del micrófono';
@override
String get letsTheRecorderAdjustInputLevel =>
'Permite al grabador ajustar el nivel de entrada';
@override
String get echoCancellation => 'Cancelación de eco';
@override
String get noiseSuppression => 'Supresión de ruido';
@override
String get trimSilenceInVoiceMessages =>
'Recortar silencio en mensajes de voz';
@override
String get compressor => 'Compresor';
@override
String get limiter => 'Limitador';
@override
String get autoGain => 'Ganancia automática';
@override
String get echoCancel => 'Eco';
@override
String get noiseSuppress => 'Ruido';
@override
String get silenceTrim => 'Silencio';
@override
String get maxImageSize => 'Tamaño máximo de imagen';
@override
String get imageCompression => 'Compresión de imagen';
@override
String get grayscale => 'Escala de grises';
@override
String get ultraMode => 'Modo ultra';
@override
String get fastPrivateGpsUpdates => 'Actualizaciones GPS privadas rápidas';
@override
String get movementThreshold => 'Umbral de movimiento';
@override
String get fastGpsMovementThreshold => 'Umbral de movimiento GPS rápido';
@override
String get fastGpsActiveuseInterval => 'Intervalo de uso activo GPS rápido';
@override
String get activeuseUpdateInterval =>
'Intervalo de actualización de uso activo';
@override
String get repeatNearbyTraffic => 'Repetir tráfico cercano';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Retransmitir a través de repetidores en la red';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Solo cercanos, sin inundación de repetidores';
@override
String get multihop => 'Multi-salto';
@override
String get createProfile => 'Crear perfil';
@override
String get renameProfile => 'Renombrar perfil';
@override
String get newProfile => 'Nuevo perfil';
@override
String get manageProfiles => 'Gestionar perfiles';
@override
String get enableProfilesToStartManagingThem =>
'Habilite los perfiles para comenzar a gestionarlos.';
@override
String get openMessage => 'Abrir mensaje';
@override
String get jumpToTheRelatedSarMessage => 'Ir al mensaje SAR relacionado';
@override
String get removeSarMarker => 'Eliminar marcador SAR';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Seleccione un destino para enviar el marcador SAR';
@override
String get sarMarkerBroadcastToPublicChannel =>
'Marcador SAR transmitido al canal público';
@override
String get sarMarkerSentToRoom => 'Marcador SAR enviado a la sala';
@override
String get loadFromGallery => 'Cargar desde galería';
@override
String get replaceImage => 'Reemplazar imagen';
@override
String get selectFromGallery => 'Seleccionar de galería';
@override
String get team => 'Equipo';
@override
String get found => 'Encontrado';
@override
String get staging => 'Área de reunión';
@override
String get object => 'Objeto';
@override
String get quiet => 'Silencioso';
@override
String get moderate => 'Moderado';
@override
String get busy => 'Ocupado';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'El escaneo de espectro no encontró frecuencias candidatas';
@override
String get searchMessages => 'Buscar mensajes';
@override
String get sendImageFromGallery => 'Enviar imagen desde galería';
@override
String get takePhoto => 'Tomar foto';
@override
String get dmOnly => 'Solo mensaje directo';
@override
String get allMessages => 'Todos los mensajes';
@override
String get sendToPublicChannel => '¿Enviar al canal público?';
@override
String get selectMarkerTypeAndDestination =>
'Seleccione tipo de marcador y destino';
@override
String get noDestinationsAvailableLabel => 'No hay destinos disponibles';
@override
String get image => 'Imagen';
@override
String get format => 'Formato';
@override
String get dimensions => 'Dimensiones';
@override
String get segments => 'Segmentos';
@override
String get transfers => 'Transferencias';
@override
String get downloadedBy => 'Descargado por';
@override
String get saveDiscoverySettings => 'Guardar ajustes de descubrimiento';
@override
String get savePublicInfo => 'Guardar información pública';
@override
String get saveRadioSettings => 'Guardar ajustes de radio';
@override
String get savePath => 'Guardar ruta';
@override
String get wipeDeviceData => 'Borrar datos del dispositivo';
@override
String get wipeDevice => 'Borrar dispositivo';
@override
String get destructiveDeviceActions =>
'Acciones destructivas del dispositivo.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Elija un preajuste o ajuste la configuración de radio.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Elija el nombre y la ubicación que comparte este dispositivo.';
@override
String get availableSpaceOnThisDevice =>
'Espacio disponible en este dispositivo.';
@override
String get used => 'Usado';
@override
String get total => 'Total';
@override
String get renameValue => 'Renombrar valor';
@override
String get customizeFields => 'Personalizar campos';
@override
String get livePreview => 'Vista previa en vivo';
@override
String get refreshSchedule => 'Programación de actualización';
@override
String get noResponse => 'Sin respuesta';
@override
String get refreshing => 'Actualizando';
@override
String get unavailable => 'No disponible';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Elija un relay o nodo para observar.';
@override
String get publicKeyLabel => 'Clave pública';
@override
String get alreadyInContacts => 'Ya en contactos';
@override
String get connectToADeviceBeforeAddingContacts =>
'Conéctese a un dispositivo antes de añadir contactos';
@override
String get fromContacts => 'De contactos';
@override
String get onlineOnly => 'Solo en línea';
@override
String get inBoth => 'En ambos';
@override
String get source => 'Fuente';
@override
String get manualRouteEdit => 'Edición manual de ruta';
@override
String get observedMeshRoute => 'Ruta mesh observada';
@override
String get allMessagesCleared => 'Todos los mensajes borrados';
@override
String get onlineTraceDatabaseCleared => 'Base de datos de trazas borrada';
@override
String get packetLogsCleared => 'Registros de paquetes borrados';
@override
String get hexDataCopiedToClipboard => 'Datos hex copiados al portapapeles';
@override
String get developerModeEnabled => 'Modo desarrollador activado';
@override
String get developerModeDisabled => 'Modo desarrollador desactivado';
@override
String get clipboardIsEmpty => 'El portapapeles está vacío';
@override
String get contactImported => 'Contacto importado';
@override
String get contactLinkCopiedToClipboard =>
'Enlace de contacto copiado al portapapeles';
@override
String get failedToExportContact => 'Error al exportar el contacto';
@override
String get noLogsToExport => 'No hay registros para exportar';
@override
String get exportAsCsv => 'Exportar como CSV';
@override
String get exportAsText => 'Exportar como texto';
@override
String get receivedRfc3339 => 'Recibido (RFC3339)';
@override
String get buildTime => 'Hora de compilación';
@override
String get downloadUrlNotAvailable => 'URL de descarga no disponible';
@override
String get cannotOpenDownloadUrl => 'No se puede abrir la URL de descarga';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'La comprobación de actualizaciones solo está disponible en Android';
@override
String get youAreRunningTheLatestVersion =>
'Está ejecutando la última versión';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Actualización disponible pero URL de descarga no encontrada';
@override
String get startTictactoe => 'Iniciar Tic-Tac-Toe';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe no disponible';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: oponente desconocido';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: esperando inicio';
@override
String get acceptsShareLinks => 'Acepta enlaces compartidos';
@override
String get supportsRawHex => 'Soporta hex sin procesar';
@override
String get clipboardfriendly => 'Compatible con portapapeles';
@override
String get captured => 'Capturado';
@override
String get size => 'Tamaño';
@override
String get noCustomChannelsToClear =>
'No hay canales personalizados que borrar.';
@override
String get noDeviceContactsToClear =>
'No hay contactos del dispositivo que borrar.';
}

View File

@@ -1936,4 +1936,774 @@ class AppLocalizationsFr extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Échec du réglage de la route : $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'Délai ACK expiré';
@override
String get opcode => 'Opcode';
@override
String get payload => 'Charge utile';
@override
String get hops => 'Sauts';
@override
String get hashSize => 'Taille du hash';
@override
String get pathBytes => 'Octets du chemin';
@override
String get selectedPath => 'Chemin sélectionné';
@override
String get estimatedTx => 'Émission estimée';
@override
String get senderToReceipt => 'Émetteur au reçu';
@override
String get receivedCopies => 'Copies reçues';
@override
String get retryCause => 'Cause de la relance';
@override
String get retryMode => 'Mode de relance';
@override
String get retryResult => 'Résultat de la relance';
@override
String get lastRetry => 'Dernière relance';
@override
String get rxPackets => 'Paquets RX';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Débit';
@override
String get window => 'Fenêtre';
@override
String get posttxDelay => 'Délai post-émission';
@override
String get bandpass => 'Passe-bande';
@override
String get bandpassFilterVoice => 'Filtre passe-bande voix';
@override
String get frequency => 'Fréquence';
@override
String get australia => 'Australie';
@override
String get australiaNarrow => 'Australie (Étroit)';
@override
String get australiaQld => 'Australie: QLD';
@override
String get australiaSaWa => 'Australie: SA, WA';
@override
String get newZealand => 'Nouvelle-Zélande';
@override
String get newZealandNarrow => 'Nouvelle-Zélande (Étroit)';
@override
String get switzerland => 'Suisse';
@override
String get portugal433 => 'Portugal 433';
@override
String get portugal868 => 'Portugal 868';
@override
String get czechRepublicNarrow => 'République tchèque (Étroit)';
@override
String get eu433mhzLongRange => 'UE 433MHz (Longue Portée)';
@override
String get euukDeprecated => 'UE/RU (Obsolète)';
@override
String get euukNarrow => 'UE/RU (Étroit)';
@override
String get usacanadaRecommended => 'USA/Canada (Recommandé)';
@override
String get vietnamDeprecated => 'Vietnam (Obsolète)';
@override
String get vietnamNarrow => 'Vietnam (Étroit)';
@override
String get active => 'Actif';
@override
String get addContact => 'Ajouter un contact';
@override
String get all => 'Tout';
@override
String get autoResolve => 'Résolution automatique';
@override
String get clearAllLabel => 'Tout effacer';
@override
String get clearRelays => 'Effacer les relais';
@override
String get clearFilters => 'Effacer les filtres';
@override
String get clearRoute => 'Effacer la route';
@override
String get clearMessages => 'Effacer les messages';
@override
String get clearScale => 'Effacer l\'échelle';
@override
String get clearDiscoveries => 'Effacer les découvertes';
@override
String get clearOnlineTraceDatabase => 'Effacer la base de traces';
@override
String get clearAllChannels => 'Effacer tous les canaux';
@override
String get clearAllContacts => 'Effacer tous les contacts';
@override
String get clearChannels => 'Effacer les canaux';
@override
String get clearContacts => 'Effacer les contacts';
@override
String get clearPathOnMaxRetry => 'Effacer le chemin au max de relances';
@override
String get create => 'Créer';
@override
String get custom => 'Personnalisé';
@override
String get defaultValue => 'Par défaut';
@override
String get duplicate => 'Dupliquer';
@override
String get editName => 'Modifier le nom';
@override
String get open => 'Ouvrir';
@override
String get paste => 'Coller';
@override
String get preview => 'Aperçu';
@override
String get remove => 'Supprimer';
@override
String get rename => 'Renommer';
@override
String get resolveAll => 'Résoudre tout';
@override
String get send => 'Envoyer';
@override
String get sendAnyway => 'Envoyer quand même';
@override
String get share => 'Partager';
@override
String get shareContact => 'Partager le contact';
@override
String get trace => 'Trace';
@override
String get use => 'Utiliser';
@override
String get useSelectedFrequency => 'Utiliser la fréquence sélectionnée';
@override
String get discovery => 'Découverte';
@override
String get discoverRepeaters => 'Découvrir les répéteurs';
@override
String get discoverSensors => 'Découvrir les capteurs';
@override
String get repeaterDiscoverySent => 'Découverte de répéteurs envoyée';
@override
String get sensorDiscoverySent => 'Découverte de capteurs envoyée';
@override
String get clearedPendingDiscoveries => 'Découvertes en attente effacées.';
@override
String get autoDiscovery => 'Découverte automatique';
@override
String get enableAutomaticAdding => 'Activer l\'ajout automatique';
@override
String get autoaddRepeaters => 'Ajout auto des répéteurs';
@override
String get autoaddRoomServers => 'Ajout auto des serveurs de salle';
@override
String get autoaddSensors => 'Ajout auto des capteurs';
@override
String get autoaddUsers => 'Ajout auto des utilisateurs';
@override
String get overwriteOldestWhenFull => 'Écraser les plus anciens si plein';
@override
String get storage => 'Stockage';
@override
String get dangerZone => 'Zone de danger';
@override
String get profiles => 'Profils';
@override
String get favourites => 'Favoris';
@override
String get sensors => 'Capteurs';
@override
String get others => 'Autres';
@override
String get gpsModule => 'Module GPS';
@override
String get liveTraffic => 'Trafic en direct';
@override
String get repeatersMap => 'Carte des répéteurs';
@override
String get spectrumScan => 'Scan spectral';
@override
String get blePacketLogs => 'Journaux de paquets BLE';
@override
String get onlineTraceDatabase => 'Base de données de traces';
@override
String get routePathByteSize => 'Taille en octets du chemin';
@override
String get messageNotifications => 'Notifications de messages';
@override
String get sarAlerts => 'Alertes SAR';
@override
String get discoveryNotifications => 'Notifications de découverte';
@override
String get updateNotifications => 'Notifications de mise à jour';
@override
String get muteWhileAppIsOpen => 'Muet quand l\'appli est ouverte';
@override
String get disableContacts => 'Désactiver les contacts';
@override
String get enableSensorsTab => 'Activer l\'onglet Capteurs';
@override
String get enableProfiles => 'Activer les profils';
@override
String get autoRouteRotation => 'Rotation automatique de route';
@override
String get nearestRepeaterFallback => 'Répéteur le plus proche en repli';
@override
String get deleteAllStoredMessageHistory => 'Supprimer tout l\'historique';
@override
String get messageFontSize => 'Taille de police des messages';
@override
String get rotateMapWithHeading => 'Tourner la carte avec le cap';
@override
String get showMapDebugInfo => 'Afficher les infos de débogage';
@override
String get openMapInFullscreen => 'Ouvrir la carte en plein écran';
@override
String get showSarMarkersLabel => 'Afficher les marqueurs SAR';
@override
String get displaySarMarkersOnTheMainMap =>
'Afficher les marqueurs SAR sur la carte';
@override
String get showAllContactTrailsLabel =>
'Afficher toutes les traces de contacts';
@override
String get hideRepeatersOnMap => 'Masquer les répéteurs sur la carte';
@override
String get setMapScale => 'Définir l\'échelle de la carte';
@override
String get customMapScaleSaved =>
'Échelle de carte personnalisée enregistrée';
@override
String get voiceBitrate => 'Débit vocal';
@override
String get voiceCompressor => 'Compresseur vocal';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Équilibre les niveaux faibles et forts';
@override
String get voiceLimiter => 'Limiteur vocal';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Empêche l\'écrêtage avant l\'encodage';
@override
String get micAutoGain => 'Gain auto du micro';
@override
String get letsTheRecorderAdjustInputLevel =>
'Laisse l\'enregistreur ajuster le niveau d\'entrée';
@override
String get echoCancellation => 'Annulation d\'écho';
@override
String get noiseSuppression => 'Suppression du bruit';
@override
String get trimSilenceInVoiceMessages =>
'Couper le silence dans les messages vocaux';
@override
String get compressor => 'Compresseur';
@override
String get limiter => 'Limiteur';
@override
String get autoGain => 'Gain automatique';
@override
String get echoCancel => 'Écho';
@override
String get noiseSuppress => 'Bruit';
@override
String get silenceTrim => 'Silence';
@override
String get maxImageSize => 'Taille d\'image maximale';
@override
String get imageCompression => 'Compression d\'image';
@override
String get grayscale => 'Niveaux de gris';
@override
String get ultraMode => 'Mode ultra';
@override
String get fastPrivateGpsUpdates => 'Mises à jour GPS privées rapides';
@override
String get movementThreshold => 'Seuil de mouvement';
@override
String get fastGpsMovementThreshold => 'Seuil de mouvement GPS rapide';
@override
String get fastGpsActiveuseInterval =>
'Intervalle d\'utilisation active GPS rapide';
@override
String get activeuseUpdateInterval =>
'Intervalle de mise à jour en utilisation active';
@override
String get repeatNearbyTraffic => 'Répéter le trafic proche';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Relayer via les répéteurs du réseau';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Proximité seulement, sans inondation';
@override
String get multihop => 'Multi-saut';
@override
String get createProfile => 'Créer un profil';
@override
String get renameProfile => 'Renommer le profil';
@override
String get newProfile => 'Nouveau profil';
@override
String get manageProfiles => 'Gérer les profils';
@override
String get enableProfilesToStartManagingThem =>
'Activez les profils pour commencer à les gérer.';
@override
String get openMessage => 'Ouvrir le message';
@override
String get jumpToTheRelatedSarMessage => 'Aller au message SAR associé';
@override
String get removeSarMarker => 'Supprimer le marqueur SAR';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Sélectionnez une destination pour envoyer le marqueur';
@override
String get sarMarkerBroadcastToPublicChannel =>
'Marqueur SAR diffusé sur le canal public';
@override
String get sarMarkerSentToRoom => 'Marqueur SAR envoyé à la salle';
@override
String get loadFromGallery => 'Charger depuis la galerie';
@override
String get replaceImage => 'Remplacer l\'image';
@override
String get selectFromGallery => 'Sélectionner depuis la galerie';
@override
String get team => 'Équipe';
@override
String get found => 'Trouvé';
@override
String get staging => 'Zone de regroupement';
@override
String get object => 'Objet';
@override
String get quiet => 'Calme';
@override
String get moderate => 'Modéré';
@override
String get busy => 'Occupé';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'Le scan spectral n\'a trouvé aucune fréquence candidate';
@override
String get searchMessages => 'Rechercher des messages';
@override
String get sendImageFromGallery => 'Envoyer une image de la galerie';
@override
String get takePhoto => 'Prendre une photo';
@override
String get dmOnly => 'Message direct uniquement';
@override
String get allMessages => 'Tous les messages';
@override
String get sendToPublicChannel => 'Envoyer au canal public ?';
@override
String get selectMarkerTypeAndDestination =>
'Sélectionnez le type de marqueur et la destination';
@override
String get noDestinationsAvailableLabel => 'Aucune destination disponible';
@override
String get image => 'Image';
@override
String get format => 'Format';
@override
String get dimensions => 'Dimensions';
@override
String get segments => 'Segments';
@override
String get transfers => 'Transferts';
@override
String get downloadedBy => 'Téléchargé par';
@override
String get saveDiscoverySettings =>
'Enregistrer les paramètres de découverte';
@override
String get savePublicInfo => 'Enregistrer les infos publiques';
@override
String get saveRadioSettings => 'Enregistrer les paramètres radio';
@override
String get savePath => 'Enregistrer le chemin';
@override
String get wipeDeviceData => 'Effacer les données de l\'appareil';
@override
String get wipeDevice => 'Effacer l\'appareil';
@override
String get destructiveDeviceActions => 'Actions destructives de l\'appareil.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Choisissez un préréglage ou ajustez les paramètres radio.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Choisissez le nom et l\'emplacement partagés par cet appareil.';
@override
String get availableSpaceOnThisDevice =>
'Espace disponible sur cet appareil.';
@override
String get used => 'Utilisé';
@override
String get total => 'Total';
@override
String get renameValue => 'Renommer la valeur';
@override
String get customizeFields => 'Personnaliser les champs';
@override
String get livePreview => 'Aperçu en direct';
@override
String get refreshSchedule => 'Planification d\'actualisation';
@override
String get noResponse => 'Pas de réponse';
@override
String get refreshing => 'Actualisation';
@override
String get unavailable => 'Indisponible';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Choisissez un relais ou nœud à surveiller.';
@override
String get publicKeyLabel => 'Clé publique';
@override
String get alreadyInContacts => 'Déjà dans les contacts';
@override
String get connectToADeviceBeforeAddingContacts =>
'Connectez-vous à un appareil avant d\'ajouter des contacts';
@override
String get fromContacts => 'Des contacts';
@override
String get onlineOnly => 'En ligne uniquement';
@override
String get inBoth => 'Dans les deux';
@override
String get source => 'Source';
@override
String get manualRouteEdit => 'Édition manuelle de route';
@override
String get observedMeshRoute => 'Route mesh observée';
@override
String get allMessagesCleared => 'Tous les messages effacés';
@override
String get onlineTraceDatabaseCleared => 'Base de données de traces effacée';
@override
String get packetLogsCleared => 'Journaux de paquets effacés';
@override
String get hexDataCopiedToClipboard =>
'Données hex copiées dans le presse-papiers';
@override
String get developerModeEnabled => 'Mode développeur activé';
@override
String get developerModeDisabled => 'Mode développeur désactivé';
@override
String get clipboardIsEmpty => 'Le presse-papiers est vide';
@override
String get contactImported => 'Contact importé';
@override
String get contactLinkCopiedToClipboard =>
'Lien du contact copié dans le presse-papiers';
@override
String get failedToExportContact => 'Échec de l\'exportation du contact';
@override
String get noLogsToExport => 'Aucun journal à exporter';
@override
String get exportAsCsv => 'Exporter en CSV';
@override
String get exportAsText => 'Exporter en texte';
@override
String get receivedRfc3339 => 'Reçu (RFC3339)';
@override
String get buildTime => 'Heure de build';
@override
String get downloadUrlNotAvailable => 'URL de téléchargement non disponible';
@override
String get cannotOpenDownloadUrl =>
'Impossible d\'ouvrir l\'URL de téléchargement';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'La vérification des mises à jour n\'est disponible que sur Android';
@override
String get youAreRunningTheLatestVersion =>
'Vous utilisez la dernière version';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Mise à jour disponible mais URL de téléchargement introuvable';
@override
String get startTictactoe => 'Démarrer Tic-Tac-Toe';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe indisponible';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: adversaire inconnu';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: en attente du début';
@override
String get acceptsShareLinks => 'Accepte les liens partagés';
@override
String get supportsRawHex => 'Supporte le hex brut';
@override
String get clipboardfriendly => 'Compatible avec le presse-papiers';
@override
String get captured => 'Capturé';
@override
String get size => 'Taille';
@override
String get noCustomChannelsToClear => 'Aucun canal personnalisé à effacer.';
@override
String get noDeviceContactsToClear =>
'Aucun contact de l\'appareil à effacer.';
}

View File

@@ -1912,4 +1912,767 @@ class AppLocalizationsHr extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Postavljanje rute nije uspjelo: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'ACK istek vremena';
@override
String get opcode => 'Opkod';
@override
String get payload => 'Sadržaj';
@override
String get hops => 'Skokovi';
@override
String get hashSize => 'Veličina hash-a';
@override
String get pathBytes => 'Bajtovi putanje';
@override
String get selectedPath => 'Odabrana putanja';
@override
String get estimatedTx => 'Procijenjeno slanje';
@override
String get senderToReceipt => 'Od pošiljatelja do primitka';
@override
String get receivedCopies => 'Primljene kopije';
@override
String get retryCause => 'Razlog ponovnog pokušaja';
@override
String get retryMode => 'Način ponovnog pokušaja';
@override
String get retryResult => 'Rezultat ponovnog pokušaja';
@override
String get lastRetry => 'Zadnji pokušaj';
@override
String get rxPackets => 'RX paketi';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Brzina';
@override
String get window => 'Prozor';
@override
String get posttxDelay => 'Kašnjenje nakon slanja';
@override
String get bandpass => 'Pojasni filtar';
@override
String get bandpassFilterVoice => 'Pojasni filtar glasa';
@override
String get frequency => 'Frekvencija';
@override
String get australia => 'Australija';
@override
String get australiaNarrow => 'Australija (usko)';
@override
String get australiaQld => 'Australija: QLD';
@override
String get australiaSaWa => 'Australija: SA, WA';
@override
String get newZealand => 'Novi Zeland';
@override
String get newZealandNarrow => 'Novi Zeland (usko)';
@override
String get switzerland => 'Švicarska';
@override
String get portugal433 => 'Portugal 433';
@override
String get portugal868 => 'Portugal 868';
@override
String get czechRepublicNarrow => 'Češka (usko)';
@override
String get eu433mhzLongRange => 'EU 433MHz (veliki domet)';
@override
String get euukDeprecated => 'EU/UK (zastarjelo)';
@override
String get euukNarrow => 'EU/UK (usko)';
@override
String get usacanadaRecommended => 'SAD/Kanada (preporučeno)';
@override
String get vietnamDeprecated => 'Vijetnam (zastarjelo)';
@override
String get vietnamNarrow => 'Vijetnam (usko)';
@override
String get active => 'Aktivno';
@override
String get addContact => 'Dodaj kontakt';
@override
String get all => 'Sve';
@override
String get autoResolve => 'Automatski razriješi';
@override
String get clearAllLabel => 'Očisti sve';
@override
String get clearRelays => 'Očisti posrednike';
@override
String get clearFilters => 'Očisti filtre';
@override
String get clearRoute => 'Očisti putanju';
@override
String get clearMessages => 'Očisti poruke';
@override
String get clearScale => 'Očisti mjerilo';
@override
String get clearDiscoveries => 'Očisti otkrića';
@override
String get clearOnlineTraceDatabase => 'Očisti bazu mrežnih tragova';
@override
String get clearAllChannels => 'Očisti sve kanale';
@override
String get clearAllContacts => 'Očisti sve kontakte';
@override
String get clearChannels => 'Očisti kanale';
@override
String get clearContacts => 'Očisti kontakte';
@override
String get clearPathOnMaxRetry => 'Očisti putanju kod maks. pokušaja';
@override
String get create => 'Stvori';
@override
String get custom => 'Prilagođeno';
@override
String get defaultValue => 'Zadano';
@override
String get duplicate => 'Dupliciraj';
@override
String get editName => 'Uredi ime';
@override
String get open => 'Otvori';
@override
String get paste => 'Zalijepi';
@override
String get preview => 'Pregled';
@override
String get remove => 'Ukloni';
@override
String get rename => 'Preimenuj';
@override
String get resolveAll => 'Razriješi sve';
@override
String get send => 'Pošalji';
@override
String get sendAnyway => 'Svejedno pošalji';
@override
String get share => 'Dijeli';
@override
String get shareContact => 'Dijeli kontakt';
@override
String get trace => 'Praćenje';
@override
String get use => 'Koristi';
@override
String get useSelectedFrequency => 'Koristi odabranu frekvenciju';
@override
String get discovery => 'Otkrivanje';
@override
String get discoverRepeaters => 'Otkrij posrednike';
@override
String get discoverSensors => 'Otkrij senzore';
@override
String get repeaterDiscoverySent => 'Otkrivanje posrednika poslano';
@override
String get sensorDiscoverySent => 'Otkrivanje senzora poslano';
@override
String get clearedPendingDiscoveries => 'Očišćena čekajuća otkrića.';
@override
String get autoDiscovery => 'Automatsko otkrivanje';
@override
String get enableAutomaticAdding => 'Omogući automatsko dodavanje';
@override
String get autoaddRepeaters => 'Automatski dodaj posrednike';
@override
String get autoaddRoomServers => 'Automatski dodaj poslužitelje soba';
@override
String get autoaddSensors => 'Automatski dodaj senzore';
@override
String get autoaddUsers => 'Automatski dodaj korisnike';
@override
String get overwriteOldestWhenFull => 'Prepiši najstarije kad je puno';
@override
String get storage => 'Pohrana';
@override
String get dangerZone => 'Opasna zona';
@override
String get profiles => 'Profili';
@override
String get favourites => 'Favoriti';
@override
String get sensors => 'Senzori';
@override
String get others => 'Ostali';
@override
String get gpsModule => 'GPS modul';
@override
String get liveTraffic => 'Promet uživo';
@override
String get repeatersMap => 'Karta posrednika';
@override
String get spectrumScan => 'Spektralno skeniranje';
@override
String get blePacketLogs => 'BLE zapisi paketa';
@override
String get onlineTraceDatabase => 'Baza mrežnih tragova';
@override
String get routePathByteSize => 'Veličina putanje u bajtima';
@override
String get messageNotifications => 'Obavijesti o porukama';
@override
String get sarAlerts => 'SAR upozorenja';
@override
String get discoveryNotifications => 'Obavijesti o otkrivanju';
@override
String get updateNotifications => 'Obavijesti o ažuriranju';
@override
String get muteWhileAppIsOpen => 'Utišaj dok je aplikacija otvorena';
@override
String get disableContacts => 'Onemogući kontakte';
@override
String get enableSensorsTab => 'Omogući karticu Senzori';
@override
String get enableProfiles => 'Omogući profile';
@override
String get autoRouteRotation => 'Automatsko rotiranje putanje';
@override
String get nearestRepeaterFallback => 'Najbliži posrednik kao rezerva';
@override
String get deleteAllStoredMessageHistory =>
'Izbriši svu pohranjenu povijest poruka';
@override
String get messageFontSize => 'Veličina fonta poruka';
@override
String get rotateMapWithHeading => 'Rotiraj kartu prema smjeru';
@override
String get showMapDebugInfo => 'Prikaži debug info karte';
@override
String get openMapInFullscreen => 'Otvori kartu na cijelom zaslonu';
@override
String get showSarMarkersLabel => 'Prikaži SAR oznake';
@override
String get displaySarMarkersOnTheMainMap =>
'Prikaži SAR oznake na glavnoj karti';
@override
String get showAllContactTrailsLabel => 'Prikaži sve tragove kontakata';
@override
String get hideRepeatersOnMap => 'Sakrij posrednike na karti';
@override
String get setMapScale => 'Postavi mjerilo karte';
@override
String get customMapScaleSaved => 'Prilagođeno mjerilo karte spremljeno';
@override
String get voiceBitrate => 'Bitna brzina glasa';
@override
String get voiceCompressor => 'Kompresor glasa';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Izravnava tihe i glasne razine govora';
@override
String get voiceLimiter => 'Ograničivač glasa';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Sprječava rezanje vrhova prije kodiranja';
@override
String get micAutoGain => 'Automatsko pojačanje mikrofona';
@override
String get letsTheRecorderAdjustInputLevel =>
'Dopušta snimaču prilagodbu razine ulaza';
@override
String get echoCancellation => 'Poništavanje odjeka';
@override
String get noiseSuppression => 'Potiskivanje šuma';
@override
String get trimSilenceInVoiceMessages => 'Izreži tišinu u glasovnim porukama';
@override
String get compressor => 'Kompresor';
@override
String get limiter => 'Ograničivač';
@override
String get autoGain => 'Automatsko pojačanje';
@override
String get echoCancel => 'Odjek';
@override
String get noiseSuppress => 'Šum';
@override
String get silenceTrim => 'Tišina';
@override
String get maxImageSize => 'Maksimalna veličina slike';
@override
String get imageCompression => 'Kompresija slike';
@override
String get grayscale => 'Sivine';
@override
String get ultraMode => 'Ultra način';
@override
String get fastPrivateGpsUpdates => 'Brza privatna GPS ažuriranja';
@override
String get movementThreshold => 'Prag kretanja';
@override
String get fastGpsMovementThreshold => 'Prag kretanja za brzi GPS';
@override
String get fastGpsActiveuseInterval =>
'Interval aktivnog korištenja brzog GPS-a';
@override
String get activeuseUpdateInterval =>
'Interval ažuriranja pri aktivnom korištenju';
@override
String get repeatNearbyTraffic => 'Ponovi obližnji promet';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Proslijedi kroz posrednike kroz mrežu';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Samo obližnji, bez preplavljivanja posrednika';
@override
String get multihop => 'Višeskočno';
@override
String get createProfile => 'Stvori profil';
@override
String get renameProfile => 'Preimenuj profil';
@override
String get newProfile => 'Novi profil';
@override
String get manageProfiles => 'Upravljaj profilima';
@override
String get enableProfilesToStartManagingThem =>
'Omogućite profile da biste ih počeli upravljati.';
@override
String get openMessage => 'Otvori poruku';
@override
String get jumpToTheRelatedSarMessage => 'Skoči na povezanu SAR poruku';
@override
String get removeSarMarker => 'Ukloni SAR oznaku';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Odaberite odredište za slanje SAR oznake';
@override
String get sarMarkerBroadcastToPublicChannel =>
'SAR oznaka emitirana na javni kanal';
@override
String get sarMarkerSentToRoom => 'SAR oznaka poslana u sobu';
@override
String get loadFromGallery => 'Učitaj iz galerije';
@override
String get replaceImage => 'Zamijeni sliku';
@override
String get selectFromGallery => 'Odaberi iz galerije';
@override
String get team => 'Tim';
@override
String get found => 'Pronađeno';
@override
String get staging => 'Sabirno mjesto';
@override
String get object => 'Objekt';
@override
String get quiet => 'Tiho';
@override
String get moderate => 'Umjereno';
@override
String get busy => 'Zauzeto';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'Spektralno skeniranje nije pronašlo prikladne frekvencije';
@override
String get searchMessages => 'Pretraži poruke';
@override
String get sendImageFromGallery => 'Pošalji sliku iz galerije';
@override
String get takePhoto => 'Snimi fotografiju';
@override
String get dmOnly => 'Samo izravno';
@override
String get allMessages => 'Sve poruke';
@override
String get sendToPublicChannel => 'Pošalji na javni kanal?';
@override
String get selectMarkerTypeAndDestination =>
'Odaberite vrstu oznake i odredište';
@override
String get noDestinationsAvailableLabel => 'Nema dostupnih odredišta';
@override
String get image => 'Slika';
@override
String get format => 'Format';
@override
String get dimensions => 'Dimenzije';
@override
String get segments => 'Segmenti';
@override
String get transfers => 'Prijenosi';
@override
String get downloadedBy => 'Preuzeo';
@override
String get saveDiscoverySettings => 'Spremi postavke otkrivanja';
@override
String get savePublicInfo => 'Spremi javne podatke';
@override
String get saveRadioSettings => 'Spremi radijske postavke';
@override
String get savePath => 'Spremi putanju';
@override
String get wipeDeviceData => 'Obriši podatke uređaja';
@override
String get wipeDevice => 'Obriši uređaj';
@override
String get destructiveDeviceActions => 'Destruktivne radnje na uređaju.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Odaberite predefiniranu postavku ili fino podesite radijske postavke.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Odaberite ime i lokaciju koju ovaj uređaj dijeli.';
@override
String get availableSpaceOnThisDevice =>
'Raspoloživi prostor na ovom uređaju.';
@override
String get used => 'Korišteno';
@override
String get total => 'Ukupno';
@override
String get renameValue => 'Preimenuj vrijednost';
@override
String get customizeFields => 'Prilagodi polja';
@override
String get livePreview => 'Pregled uživo';
@override
String get refreshSchedule => 'Raspored osvježavanja';
@override
String get noResponse => 'Nema odgovora';
@override
String get refreshing => 'Osvježavam';
@override
String get unavailable => 'Nedostupno';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Odaberite posrednika ili čvor za praćenje.';
@override
String get publicKeyLabel => 'Javni ključ';
@override
String get alreadyInContacts => 'Već u kontaktima';
@override
String get connectToADeviceBeforeAddingContacts =>
'Povežite se s uređajem prije dodavanja kontakata';
@override
String get fromContacts => 'Iz kontakata';
@override
String get onlineOnly => 'Samo povezani';
@override
String get inBoth => 'U oba';
@override
String get source => 'Izvor';
@override
String get manualRouteEdit => 'Ručno uređivanje putanje';
@override
String get observedMeshRoute => 'Opažena mrežna putanja';
@override
String get allMessagesCleared => 'Sve poruke očišćene';
@override
String get onlineTraceDatabaseCleared => 'Baza mrežnih tragova očišćena';
@override
String get packetLogsCleared => 'Zapisi paketa očišćeni';
@override
String get hexDataCopiedToClipboard => 'Hex podaci kopirani u međuspremnik';
@override
String get developerModeEnabled => 'Razvojni način omogućen';
@override
String get developerModeDisabled => 'Razvojni način onemogućen';
@override
String get clipboardIsEmpty => 'Međuspremnik je prazan';
@override
String get contactImported => 'Kontakt uvezen';
@override
String get contactLinkCopiedToClipboard =>
'Poveznica na kontakt kopirana u međuspremnik';
@override
String get failedToExportContact => 'Izvoz kontakta nije uspio';
@override
String get noLogsToExport => 'Nema zapisa za izvoz';
@override
String get exportAsCsv => 'Izvezi kao CSV';
@override
String get exportAsText => 'Izvezi kao tekst';
@override
String get receivedRfc3339 => 'Primljeno (RFC3339)';
@override
String get buildTime => 'Vrijeme gradnje';
@override
String get downloadUrlNotAvailable => 'URL za preuzimanje nije dostupan';
@override
String get cannotOpenDownloadUrl => 'Ne mogu otvoriti URL za preuzimanje';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'Provjera ažuriranja dostupna je samo na Androidu';
@override
String get youAreRunningTheLatestVersion => 'Koristite najnoviju verziju';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Ažuriranje dostupno, ali URL za preuzimanje nije pronađen';
@override
String get startTictactoe => 'Pokreni Tic-Tac-Toe';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe nije dostupan';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: protivnik nepoznat';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: čekanje na početak';
@override
String get acceptsShareLinks => 'Prihvaća dijeljene veze';
@override
String get supportsRawHex => 'Podržava sirovi hex';
@override
String get clipboardfriendly => 'Pogodno za međuspremnik';
@override
String get captured => 'Zabilježeno';
@override
String get size => 'Veličina';
@override
String get noCustomChannelsToClear => 'Nema prilagođenih kanala za brisanje.';
@override
String get noDeviceContactsToClear => 'Nema kontakata uređaja za brisanje.';
}

View File

@@ -1927,4 +1927,769 @@ class AppLocalizationsIt extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Impossibile impostare il percorso: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'Timeout ACK';
@override
String get opcode => 'Opcode';
@override
String get payload => 'Payload';
@override
String get hops => 'Hop';
@override
String get hashSize => 'Dimensione hash';
@override
String get pathBytes => 'Byte percorso';
@override
String get selectedPath => 'Percorso selezionato';
@override
String get estimatedTx => 'Trasmissione stimata';
@override
String get senderToReceipt => 'Mittente a ricevuta';
@override
String get receivedCopies => 'Copie ricevute';
@override
String get retryCause => 'Causa del ritentativo';
@override
String get retryMode => 'Modalità ritentativo';
@override
String get retryResult => 'Risultato ritentativo';
@override
String get lastRetry => 'Ultimo ritentativo';
@override
String get rxPackets => 'Pacchetti RX';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Velocità';
@override
String get window => 'Finestra';
@override
String get posttxDelay => 'Ritardo post-tx';
@override
String get bandpass => 'Passa-banda';
@override
String get bandpassFilterVoice => 'Filtro passa-banda voce';
@override
String get frequency => 'Frequenza';
@override
String get australia => 'Australia';
@override
String get australiaNarrow => 'Australia (Stretto)';
@override
String get australiaQld => 'Australia: QLD';
@override
String get australiaSaWa => 'Australia: SA, WA';
@override
String get newZealand => 'Nuova Zelanda';
@override
String get newZealandNarrow => 'Nuova Zelanda (Stretto)';
@override
String get switzerland => 'Svizzera';
@override
String get portugal433 => 'Portogallo 433';
@override
String get portugal868 => 'Portogallo 868';
@override
String get czechRepublicNarrow => 'Repubblica Ceca (Stretto)';
@override
String get eu433mhzLongRange => 'UE 433MHz (Lungo Raggio)';
@override
String get euukDeprecated => 'UE/UK (Deprecato)';
@override
String get euukNarrow => 'UE/UK (Stretto)';
@override
String get usacanadaRecommended => 'USA/Canada (Consigliato)';
@override
String get vietnamDeprecated => 'Vietnam (Deprecato)';
@override
String get vietnamNarrow => 'Vietnam (Stretto)';
@override
String get active => 'Attivo';
@override
String get addContact => 'Aggiungi contatto';
@override
String get all => 'Tutto';
@override
String get autoResolve => 'Risolvi automaticamente';
@override
String get clearAllLabel => 'Cancella tutto';
@override
String get clearRelays => 'Cancella relay';
@override
String get clearFilters => 'Cancella filtri';
@override
String get clearRoute => 'Cancella percorso';
@override
String get clearMessages => 'Cancella messaggi';
@override
String get clearScale => 'Cancella scala';
@override
String get clearDiscoveries => 'Cancella scoperte';
@override
String get clearOnlineTraceDatabase => 'Cancella database tracce';
@override
String get clearAllChannels => 'Cancella tutti i canali';
@override
String get clearAllContacts => 'Cancella tutti i contatti';
@override
String get clearChannels => 'Cancella canali';
@override
String get clearContacts => 'Cancella contatti';
@override
String get clearPathOnMaxRetry => 'Cancella percorso al max ritentativo';
@override
String get create => 'Crea';
@override
String get custom => 'Personalizzato';
@override
String get defaultValue => 'Predefinito';
@override
String get duplicate => 'Duplica';
@override
String get editName => 'Modifica nome';
@override
String get open => 'Apri';
@override
String get paste => 'Incolla';
@override
String get preview => 'Anteprima';
@override
String get remove => 'Rimuovi';
@override
String get rename => 'Rinomina';
@override
String get resolveAll => 'Risolvi tutto';
@override
String get send => 'Invia';
@override
String get sendAnyway => 'Invia comunque';
@override
String get share => 'Condividi';
@override
String get shareContact => 'Condividi contatto';
@override
String get trace => 'Traccia';
@override
String get use => 'Usa';
@override
String get useSelectedFrequency => 'Usa frequenza selezionata';
@override
String get discovery => 'Scoperta';
@override
String get discoverRepeaters => 'Scopri ripetitori';
@override
String get discoverSensors => 'Scopri sensori';
@override
String get repeaterDiscoverySent => 'Scoperta ripetitori inviata';
@override
String get sensorDiscoverySent => 'Scoperta sensori inviata';
@override
String get clearedPendingDiscoveries => 'Scoperte in attesa cancellate.';
@override
String get autoDiscovery => 'Scoperta automatica';
@override
String get enableAutomaticAdding => 'Abilita aggiunta automatica';
@override
String get autoaddRepeaters => 'Aggiungi ripetitori automaticamente';
@override
String get autoaddRoomServers => 'Aggiungi server stanze automaticamente';
@override
String get autoaddSensors => 'Aggiungi sensori automaticamente';
@override
String get autoaddUsers => 'Aggiungi utenti automaticamente';
@override
String get overwriteOldestWhenFull => 'Sovrascrivi i più vecchi quando pieno';
@override
String get storage => 'Archiviazione';
@override
String get dangerZone => 'Zona pericolosa';
@override
String get profiles => 'Profili';
@override
String get favourites => 'Preferiti';
@override
String get sensors => 'Sensori';
@override
String get others => 'Altri';
@override
String get gpsModule => 'Modulo GPS';
@override
String get liveTraffic => 'Traffico in tempo reale';
@override
String get repeatersMap => 'Mappa ripetitori';
@override
String get spectrumScan => 'Scansione spettro';
@override
String get blePacketLogs => 'Log pacchetti BLE';
@override
String get onlineTraceDatabase => 'Database tracce';
@override
String get routePathByteSize => 'Dimensione percorso in byte';
@override
String get messageNotifications => 'Notifiche messaggi';
@override
String get sarAlerts => 'Avvisi SAR';
@override
String get discoveryNotifications => 'Notifiche scoperte';
@override
String get updateNotifications => 'Notifiche aggiornamenti';
@override
String get muteWhileAppIsOpen => 'Silenzia con l\'app aperta';
@override
String get disableContacts => 'Disabilita contatti';
@override
String get enableSensorsTab => 'Abilita scheda Sensori';
@override
String get enableProfiles => 'Abilita profili';
@override
String get autoRouteRotation => 'Rotazione automatica percorso';
@override
String get nearestRepeaterFallback => 'Ripetitore più vicino come fallback';
@override
String get deleteAllStoredMessageHistory =>
'Elimina tutta la cronologia messaggi';
@override
String get messageFontSize => 'Dimensione carattere messaggi';
@override
String get rotateMapWithHeading => 'Ruota mappa con direzione';
@override
String get showMapDebugInfo => 'Mostra info debug mappa';
@override
String get openMapInFullscreen => 'Apri mappa a schermo intero';
@override
String get showSarMarkersLabel => 'Mostra marcatori SAR';
@override
String get displaySarMarkersOnTheMainMap =>
'Mostra marcatori SAR sulla mappa principale';
@override
String get showAllContactTrailsLabel => 'Mostra tutti i percorsi contatti';
@override
String get hideRepeatersOnMap => 'Nascondi ripetitori sulla mappa';
@override
String get setMapScale => 'Imposta scala mappa';
@override
String get customMapScaleSaved => 'Scala mappa personalizzata salvata';
@override
String get voiceBitrate => 'Bitrate voce';
@override
String get voiceCompressor => 'Compressore voce';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Bilancia i livelli di voce bassi e alti';
@override
String get voiceLimiter => 'Limitatore voce';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Previene il clipping prima della codifica';
@override
String get micAutoGain => 'Guadagno automatico microfono';
@override
String get letsTheRecorderAdjustInputLevel =>
'Permette al registratore di regolare il livello';
@override
String get echoCancellation => 'Cancellazione eco';
@override
String get noiseSuppression => 'Soppressione rumore';
@override
String get trimSilenceInVoiceMessages =>
'Taglia silenzio nei messaggi vocali';
@override
String get compressor => 'Compressore';
@override
String get limiter => 'Limitatore';
@override
String get autoGain => 'Guadagno automatico';
@override
String get echoCancel => 'Eco';
@override
String get noiseSuppress => 'Rumore';
@override
String get silenceTrim => 'Silenzio';
@override
String get maxImageSize => 'Dimensione massima immagine';
@override
String get imageCompression => 'Compressione immagine';
@override
String get grayscale => 'Scala di grigi';
@override
String get ultraMode => 'Modalità ultra';
@override
String get fastPrivateGpsUpdates => 'Aggiornamenti GPS privati veloci';
@override
String get movementThreshold => 'Soglia di movimento';
@override
String get fastGpsMovementThreshold => 'Soglia movimento GPS veloce';
@override
String get fastGpsActiveuseInterval => 'Intervallo uso attivo GPS veloce';
@override
String get activeuseUpdateInterval => 'Intervallo aggiornamento uso attivo';
@override
String get repeatNearbyTraffic => 'Ripeti traffico vicino';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Inoltra tramite ripetitori nella rete';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Solo vicini, senza flood dei ripetitori';
@override
String get multihop => 'Multi-hop';
@override
String get createProfile => 'Crea profilo';
@override
String get renameProfile => 'Rinomina profilo';
@override
String get newProfile => 'Nuovo profilo';
@override
String get manageProfiles => 'Gestisci profili';
@override
String get enableProfilesToStartManagingThem =>
'Abilita i profili per iniziare a gestirli.';
@override
String get openMessage => 'Apri messaggio';
@override
String get jumpToTheRelatedSarMessage => 'Vai al messaggio SAR correlato';
@override
String get removeSarMarker => 'Rimuovi marcatore SAR';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Seleziona una destinazione per inviare il marcatore SAR';
@override
String get sarMarkerBroadcastToPublicChannel =>
'Marcatore SAR trasmesso al canale pubblico';
@override
String get sarMarkerSentToRoom => 'Marcatore SAR inviato alla stanza';
@override
String get loadFromGallery => 'Carica dalla galleria';
@override
String get replaceImage => 'Sostituisci immagine';
@override
String get selectFromGallery => 'Seleziona dalla galleria';
@override
String get team => 'Squadra';
@override
String get found => 'Trovato';
@override
String get staging => 'Area di raccolta';
@override
String get object => 'Oggetto';
@override
String get quiet => 'Silenzioso';
@override
String get moderate => 'Moderato';
@override
String get busy => 'Occupato';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'La scansione spettro non ha trovato frequenze candidate';
@override
String get searchMessages => 'Cerca messaggi';
@override
String get sendImageFromGallery => 'Invia immagine dalla galleria';
@override
String get takePhoto => 'Scatta foto';
@override
String get dmOnly => 'Solo messaggio diretto';
@override
String get allMessages => 'Tutti i messaggi';
@override
String get sendToPublicChannel => 'Inviare al canale pubblico?';
@override
String get selectMarkerTypeAndDestination =>
'Seleziona tipo marcatore e destinazione';
@override
String get noDestinationsAvailableLabel => 'Nessuna destinazione disponibile';
@override
String get image => 'Immagine';
@override
String get format => 'Formato';
@override
String get dimensions => 'Dimensioni';
@override
String get segments => 'Segmenti';
@override
String get transfers => 'Trasferimenti';
@override
String get downloadedBy => 'Scaricato da';
@override
String get saveDiscoverySettings => 'Salva impostazioni scoperta';
@override
String get savePublicInfo => 'Salva info pubbliche';
@override
String get saveRadioSettings => 'Salva impostazioni radio';
@override
String get savePath => 'Salva percorso';
@override
String get wipeDeviceData => 'Cancella dati dispositivo';
@override
String get wipeDevice => 'Cancella dispositivo';
@override
String get destructiveDeviceActions => 'Azioni distruttive del dispositivo.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Scegli un preset o personalizza le impostazioni radio.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Scegli il nome e la posizione condivisi da questo dispositivo.';
@override
String get availableSpaceOnThisDevice =>
'Spazio disponibile su questo dispositivo.';
@override
String get used => 'Usato';
@override
String get total => 'Totale';
@override
String get renameValue => 'Rinomina valore';
@override
String get customizeFields => 'Personalizza campi';
@override
String get livePreview => 'Anteprima dal vivo';
@override
String get refreshSchedule => 'Programma aggiornamento';
@override
String get noResponse => 'Nessuna risposta';
@override
String get refreshing => 'Aggiornamento';
@override
String get unavailable => 'Non disponibile';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Scegli un relay o nodo da monitorare.';
@override
String get publicKeyLabel => 'Chiave pubblica';
@override
String get alreadyInContacts => 'Già nei contatti';
@override
String get connectToADeviceBeforeAddingContacts =>
'Connettiti a un dispositivo prima di aggiungere contatti';
@override
String get fromContacts => 'Dai contatti';
@override
String get onlineOnly => 'Solo online';
@override
String get inBoth => 'In entrambi';
@override
String get source => 'Origine';
@override
String get manualRouteEdit => 'Modifica manuale percorso';
@override
String get observedMeshRoute => 'Percorso mesh osservato';
@override
String get allMessagesCleared => 'Tutti i messaggi cancellati';
@override
String get onlineTraceDatabaseCleared => 'Database tracce cancellata';
@override
String get packetLogsCleared => 'Log pacchetti cancellati';
@override
String get hexDataCopiedToClipboard => 'Dati hex copiati negli appunti';
@override
String get developerModeEnabled => 'Modalità sviluppatore attivata';
@override
String get developerModeDisabled => 'Modalità sviluppatore disattivata';
@override
String get clipboardIsEmpty => 'Gli appunti sono vuoti';
@override
String get contactImported => 'Contatto importato';
@override
String get contactLinkCopiedToClipboard =>
'Link contatto copiato negli appunti';
@override
String get failedToExportContact => 'Esportazione contatto fallita';
@override
String get noLogsToExport => 'Nessun log da esportare';
@override
String get exportAsCsv => 'Esporta come CSV';
@override
String get exportAsText => 'Esporta come testo';
@override
String get receivedRfc3339 => 'Ricevuto (RFC3339)';
@override
String get buildTime => 'Ora build';
@override
String get downloadUrlNotAvailable => 'URL di download non disponibile';
@override
String get cannotOpenDownloadUrl => 'Impossibile aprire URL di download';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'Il controllo aggiornamenti è disponibile solo su Android';
@override
String get youAreRunningTheLatestVersion =>
'Stai utilizzando l\'ultima versione';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Aggiornamento disponibile ma URL di download non trovato';
@override
String get startTictactoe => 'Avvia Tic-Tac-Toe';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe non disponibile';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: avversario sconosciuto';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: in attesa dell\'inizio';
@override
String get acceptsShareLinks => 'Accetta link condivisi';
@override
String get supportsRawHex => 'Supporta hex grezzo';
@override
String get clipboardfriendly => 'Compatibile con appunti';
@override
String get captured => 'Catturato';
@override
String get size => 'Dimensione';
@override
String get noCustomChannelsToClear =>
'Nessun canale personalizzato da cancellare.';
@override
String get noDeviceContactsToClear =>
'Nessun contatto dispositivo da cancellare.';
}

View File

@@ -1924,4 +1924,769 @@ class AppLocalizationsPl extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Nie udało się ustawić trasy: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'Limit czasu ACK';
@override
String get opcode => 'Opcode';
@override
String get payload => 'Ładunek';
@override
String get hops => 'Skoki';
@override
String get hashSize => 'Rozmiar hash';
@override
String get pathBytes => 'Bajty ścieżki';
@override
String get selectedPath => 'Wybrana ścieżka';
@override
String get estimatedTx => 'Szacowane nadawanie';
@override
String get senderToReceipt => 'Od nadawcy do odbioru';
@override
String get receivedCopies => 'Otrzymane kopie';
@override
String get retryCause => 'Przyczyna ponowienia';
@override
String get retryMode => 'Tryb ponowienia';
@override
String get retryResult => 'Wynik ponowienia';
@override
String get lastRetry => 'Ostatnia próba';
@override
String get rxPackets => 'Pakiety RX';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Szybkość';
@override
String get window => 'Okno';
@override
String get posttxDelay => 'Opóźnienie po nadawaniu';
@override
String get bandpass => 'Pasmowy';
@override
String get bandpassFilterVoice => 'Filtr pasmowy głosu';
@override
String get frequency => 'Częstotliwość';
@override
String get australia => 'Australia';
@override
String get australiaNarrow => 'Australia (wąski)';
@override
String get australiaQld => 'Australia: QLD';
@override
String get australiaSaWa => 'Australia: SA, WA';
@override
String get newZealand => 'Nowa Zelandia';
@override
String get newZealandNarrow => 'Nowa Zelandia (wąski)';
@override
String get switzerland => 'Szwajcaria';
@override
String get portugal433 => 'Portugalia 433';
@override
String get portugal868 => 'Portugalia 868';
@override
String get czechRepublicNarrow => 'Czechy (wąski)';
@override
String get eu433mhzLongRange => 'EU 433MHz (daleki zasięg)';
@override
String get euukDeprecated => 'EU/UK (przestarzały)';
@override
String get euukNarrow => 'EU/UK (wąski)';
@override
String get usacanadaRecommended => 'USA/Kanada (zalecany)';
@override
String get vietnamDeprecated => 'Wietnam (przestarzały)';
@override
String get vietnamNarrow => 'Wietnam (wąski)';
@override
String get active => 'Aktywny';
@override
String get addContact => 'Dodaj kontakt';
@override
String get all => 'Wszystko';
@override
String get autoResolve => 'Automatyczne rozwiązanie';
@override
String get clearAllLabel => 'Wyczyść wszystko';
@override
String get clearRelays => 'Wyczyść przekaźniki';
@override
String get clearFilters => 'Wyczyść filtry';
@override
String get clearRoute => 'Wyczyść trasę';
@override
String get clearMessages => 'Wyczyść wiadomości';
@override
String get clearScale => 'Wyczyść skalę';
@override
String get clearDiscoveries => 'Wyczyść odkrycia';
@override
String get clearOnlineTraceDatabase => 'Wyczyść bazę śladów';
@override
String get clearAllChannels => 'Wyczyść wszystkie kanały';
@override
String get clearAllContacts => 'Wyczyść wszystkie kontakty';
@override
String get clearChannels => 'Wyczyść kanały';
@override
String get clearContacts => 'Wyczyść kontakty';
@override
String get clearPathOnMaxRetry => 'Wyczyść ścieżkę przy maks. próbie';
@override
String get create => 'Utwórz';
@override
String get custom => 'Niestandardowy';
@override
String get defaultValue => 'Domyślny';
@override
String get duplicate => 'Duplikuj';
@override
String get editName => 'Edytuj nazwę';
@override
String get open => 'Otwórz';
@override
String get paste => 'Wklej';
@override
String get preview => 'Podgląd';
@override
String get remove => 'Usuń';
@override
String get rename => 'Zmień nazwę';
@override
String get resolveAll => 'Rozwiąż wszystko';
@override
String get send => 'Wyślij';
@override
String get sendAnyway => 'Wyślij mimo to';
@override
String get share => 'Udostępnij';
@override
String get shareContact => 'Udostępnij kontakt';
@override
String get trace => 'Śledzenie';
@override
String get use => 'Użyj';
@override
String get useSelectedFrequency => 'Użyj wybranej częstotliwości';
@override
String get discovery => 'Wykrywanie';
@override
String get discoverRepeaters => 'Wykryj przekaźniki';
@override
String get discoverSensors => 'Wykryj czujniki';
@override
String get repeaterDiscoverySent => 'Wykrywanie przekaźników wysłane';
@override
String get sensorDiscoverySent => 'Wykrywanie czujników wysłane';
@override
String get clearedPendingDiscoveries => 'Wyczyszczono oczekujące odkrycia.';
@override
String get autoDiscovery => 'Automatyczne wykrywanie';
@override
String get enableAutomaticAdding => 'Włącz automatyczne dodawanie';
@override
String get autoaddRepeaters => 'Automatycznie dodaj przekaźniki';
@override
String get autoaddRoomServers => 'Automatycznie dodaj serwery pokoi';
@override
String get autoaddSensors => 'Automatycznie dodaj czujniki';
@override
String get autoaddUsers => 'Automatycznie dodaj użytkowników';
@override
String get overwriteOldestWhenFull => 'Nadpisz najstarsze gdy pełne';
@override
String get storage => 'Pamięć';
@override
String get dangerZone => 'Strefa zagrożenia';
@override
String get profiles => 'Profile';
@override
String get favourites => 'Ulubione';
@override
String get sensors => 'Czujniki';
@override
String get others => 'Inne';
@override
String get gpsModule => 'Moduł GPS';
@override
String get liveTraffic => 'Ruch na żywo';
@override
String get repeatersMap => 'Mapa przekaźników';
@override
String get spectrumScan => 'Skanowanie spektrum';
@override
String get blePacketLogs => 'Logi pakietów BLE';
@override
String get onlineTraceDatabase => 'Baza śladów';
@override
String get routePathByteSize => 'Rozmiar ścieżki w bajtach';
@override
String get messageNotifications => 'Powiadomienia o wiadomościach';
@override
String get sarAlerts => 'Alerty SAR';
@override
String get discoveryNotifications => 'Powiadomienia o wykrywaniu';
@override
String get updateNotifications => 'Powiadomienia o aktualizacjach';
@override
String get muteWhileAppIsOpen => 'Wycisz gdy aplikacja jest otwarta';
@override
String get disableContacts => 'Wyłącz kontakty';
@override
String get enableSensorsTab => 'Włącz kartę Czujniki';
@override
String get enableProfiles => 'Włącz profile';
@override
String get autoRouteRotation => 'Automatyczna rotacja trasy';
@override
String get nearestRepeaterFallback => 'Najbliższy przekaźnik awaryjny';
@override
String get deleteAllStoredMessageHistory => 'Usuń całą historię wiadomości';
@override
String get messageFontSize => 'Rozmiar czcionki wiadomości';
@override
String get rotateMapWithHeading => 'Obracaj mapę z kierunkiem';
@override
String get showMapDebugInfo => 'Pokaż info debugowania mapy';
@override
String get openMapInFullscreen => 'Otwórz mapę na pełnym ekranie';
@override
String get showSarMarkersLabel => 'Pokaż znaczniki SAR';
@override
String get displaySarMarkersOnTheMainMap =>
'Wyświetlaj znaczniki SAR na głównej mapie';
@override
String get showAllContactTrailsLabel => 'Pokaż wszystkie ślady kontaktów';
@override
String get hideRepeatersOnMap => 'Ukryj przekaźniki na mapie';
@override
String get setMapScale => 'Ustaw skalę mapy';
@override
String get customMapScaleSaved => 'Niestandardowa skala mapy zapisana';
@override
String get voiceBitrate => 'Bitrate głosu';
@override
String get voiceCompressor => 'Kompresor głosu';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Wyrównuje ciche i głośne poziomy mowy';
@override
String get voiceLimiter => 'Limiter głosu';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Zapobiega przycinaniu szczytów przed kodowaniem';
@override
String get micAutoGain => 'Automatyczne wzmocnienie mikrofonu';
@override
String get letsTheRecorderAdjustInputLevel =>
'Pozwala nagrywarkowi dostosować poziom wejścia';
@override
String get echoCancellation => 'Usuwanie echa';
@override
String get noiseSuppression => 'Tłumienie szumu';
@override
String get trimSilenceInVoiceMessages =>
'Przytnij ciszę w wiadomościach głosowych';
@override
String get compressor => 'Kompresor';
@override
String get limiter => 'Limiter';
@override
String get autoGain => 'Automatyczne wzmocnienie';
@override
String get echoCancel => 'Echo';
@override
String get noiseSuppress => 'Szum';
@override
String get silenceTrim => 'Cisza';
@override
String get maxImageSize => 'Maksymalny rozmiar obrazu';
@override
String get imageCompression => 'Kompresja obrazu';
@override
String get grayscale => 'Skala szarości';
@override
String get ultraMode => 'Tryb ultra';
@override
String get fastPrivateGpsUpdates => 'Szybkie prywatne aktualizacje GPS';
@override
String get movementThreshold => 'Próg ruchu';
@override
String get fastGpsMovementThreshold => 'Próg ruchu szybkiego GPS';
@override
String get fastGpsActiveuseInterval =>
'Interwał aktywnego użycia szybkiego GPS';
@override
String get activeuseUpdateInterval =>
'Interwał aktualizacji przy aktywnym użyciu';
@override
String get repeatNearbyTraffic => 'Powtarzaj pobliski ruch';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Przekazuj przez przekaźniki w sieci';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Tylko w pobliżu, bez zalewania przekaźników';
@override
String get multihop => 'Wieloskokowy';
@override
String get createProfile => 'Utwórz profil';
@override
String get renameProfile => 'Zmień nazwę profilu';
@override
String get newProfile => 'Nowy profil';
@override
String get manageProfiles => 'Zarządzaj profilami';
@override
String get enableProfilesToStartManagingThem =>
'Włącz profile, aby zacząć nimi zarządzać.';
@override
String get openMessage => 'Otwórz wiadomość';
@override
String get jumpToTheRelatedSarMessage =>
'Przejdź do powiązanej wiadomości SAR';
@override
String get removeSarMarker => 'Usuń znacznik SAR';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Wybierz cel wysłania znacznika SAR';
@override
String get sarMarkerBroadcastToPublicChannel =>
'Znacznik SAR wysłany na kanał publiczny';
@override
String get sarMarkerSentToRoom => 'Znacznik SAR wysłany do pokoju';
@override
String get loadFromGallery => 'Załaduj z galerii';
@override
String get replaceImage => 'Zamień obraz';
@override
String get selectFromGallery => 'Wybierz z galerii';
@override
String get team => 'Zespół';
@override
String get found => 'Znaleziono';
@override
String get staging => 'Punkt zbiórki';
@override
String get object => 'Obiekt';
@override
String get quiet => 'Cicho';
@override
String get moderate => 'Umiarkowane';
@override
String get busy => 'Zajęty';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'Skanowanie spektrum nie znalazło częstotliwości kandydatów';
@override
String get searchMessages => 'Szukaj wiadomości';
@override
String get sendImageFromGallery => 'Wyślij obraz z galerii';
@override
String get takePhoto => 'Zrób zdjęcie';
@override
String get dmOnly => 'Tylko wiadomość bezpośrednia';
@override
String get allMessages => 'Wszystkie wiadomości';
@override
String get sendToPublicChannel => 'Wysłać na kanał publiczny?';
@override
String get selectMarkerTypeAndDestination => 'Wybierz typ znacznika i cel';
@override
String get noDestinationsAvailableLabel => 'Brak dostępnych celów';
@override
String get image => 'Obraz';
@override
String get format => 'Format';
@override
String get dimensions => 'Wymiary';
@override
String get segments => 'Segmenty';
@override
String get transfers => 'Transfery';
@override
String get downloadedBy => 'Pobrane przez';
@override
String get saveDiscoverySettings => 'Zapisz ustawienia wykrywania';
@override
String get savePublicInfo => 'Zapisz informacje publiczne';
@override
String get saveRadioSettings => 'Zapisz ustawienia radia';
@override
String get savePath => 'Zapisz ścieżkę';
@override
String get wipeDeviceData => 'Wyczyść dane urządzenia';
@override
String get wipeDevice => 'Wyczyść urządzenie';
@override
String get destructiveDeviceActions => 'Destrukcyjne akcje na urządzeniu.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Wybierz ustawienie wstępne lub dostosuj ustawienia radia.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Wybierz nazwę i lokalizację udostępnianą przez to urządzenie.';
@override
String get availableSpaceOnThisDevice =>
'Dostępne miejsce na tym urządzeniu.';
@override
String get used => 'Użyto';
@override
String get total => 'Razem';
@override
String get renameValue => 'Zmień nazwę wartości';
@override
String get customizeFields => 'Dostosuj pola';
@override
String get livePreview => 'Podgląd na żywo';
@override
String get refreshSchedule => 'Harmonogram odświeżania';
@override
String get noResponse => 'Brak odpowiedzi';
@override
String get refreshing => 'Odświeżanie';
@override
String get unavailable => 'Niedostępny';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Wybierz przekaźnik lub węzeł do obserwacji.';
@override
String get publicKeyLabel => 'Klucz publiczny';
@override
String get alreadyInContacts => 'Już w kontaktach';
@override
String get connectToADeviceBeforeAddingContacts =>
'Połącz się z urządzeniem przed dodaniem kontaktów';
@override
String get fromContacts => 'Z kontaktów';
@override
String get onlineOnly => 'Tylko online';
@override
String get inBoth => 'W obu';
@override
String get source => 'Źródło';
@override
String get manualRouteEdit => 'Ręczna edycja trasy';
@override
String get observedMeshRoute => 'Obserwowana trasa sieci';
@override
String get allMessagesCleared => 'Wszystkie wiadomości wyczyszczone';
@override
String get onlineTraceDatabaseCleared => 'Baza śladów wyczyszczona';
@override
String get packetLogsCleared => 'Logi pakietów wyczyszczone';
@override
String get hexDataCopiedToClipboard => 'Dane hex skopiowane do schowka';
@override
String get developerModeEnabled => 'Tryb programisty włączony';
@override
String get developerModeDisabled => 'Tryb programisty wyłączony';
@override
String get clipboardIsEmpty => 'Schowek jest pusty';
@override
String get contactImported => 'Kontakt zaimportowany';
@override
String get contactLinkCopiedToClipboard =>
'Link kontaktu skopiowany do schowka';
@override
String get failedToExportContact => 'Nie udało się wyeksportować kontaktu';
@override
String get noLogsToExport => 'Brak logów do eksportu';
@override
String get exportAsCsv => 'Eksportuj jako CSV';
@override
String get exportAsText => 'Eksportuj jako tekst';
@override
String get receivedRfc3339 => 'Odebrano (RFC3339)';
@override
String get buildTime => 'Czas budowania';
@override
String get downloadUrlNotAvailable => 'URL pobierania niedostępny';
@override
String get cannotOpenDownloadUrl => 'Nie można otworzyć URL pobierania';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'Sprawdzanie aktualizacji dostępne tylko na Androidzie';
@override
String get youAreRunningTheLatestVersion => 'Używasz najnowszej wersji';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Aktualizacja dostępna, ale URL pobierania nie znaleziony';
@override
String get startTictactoe => 'Rozpocznij Tic-Tac-Toe';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe niedostępny';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: przeciwnik nieznany';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: oczekiwanie na start';
@override
String get acceptsShareLinks => 'Akceptuje udostępnione linki';
@override
String get supportsRawHex => 'Obsługuje surowy hex';
@override
String get clipboardfriendly => 'Przyjazne dla schowka';
@override
String get captured => 'Przechwycone';
@override
String get size => 'Rozmiar';
@override
String get noCustomChannelsToClear =>
'Brak niestandardowych kanałów do wyczyszczenia.';
@override
String get noDeviceContactsToClear =>
'Brak kontaktów urządzenia do wyczyszczenia.';
}

View File

@@ -1931,4 +1931,773 @@ class AppLocalizationsPt extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Falha ao definir a rota: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'Tempo limite ACK';
@override
String get opcode => 'Opcode';
@override
String get payload => 'Carga útil';
@override
String get hops => 'Saltos';
@override
String get hashSize => 'Tamanho do hash';
@override
String get pathBytes => 'Bytes do caminho';
@override
String get selectedPath => 'Caminho selecionado';
@override
String get estimatedTx => 'Transmissão estimada';
@override
String get senderToReceipt => 'Do remetente ao recibo';
@override
String get receivedCopies => 'Cópias recebidas';
@override
String get retryCause => 'Causa da nova tentativa';
@override
String get retryMode => 'Modo de nova tentativa';
@override
String get retryResult => 'Resultado da nova tentativa';
@override
String get lastRetry => 'Última tentativa';
@override
String get rxPackets => 'Pacotes RX';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Taxa';
@override
String get window => 'Janela';
@override
String get posttxDelay => 'Atraso pós-tx';
@override
String get bandpass => 'Passa-banda';
@override
String get bandpassFilterVoice => 'Filtro passa-banda de voz';
@override
String get frequency => 'Frequência';
@override
String get australia => 'Austrália';
@override
String get australiaNarrow => 'Austrália (Estreito)';
@override
String get australiaQld => 'Austrália: QLD';
@override
String get australiaSaWa => 'Austrália: SA, WA';
@override
String get newZealand => 'Nova Zelândia';
@override
String get newZealandNarrow => 'Nova Zelândia (Estreito)';
@override
String get switzerland => 'Suíça';
@override
String get portugal433 => 'Portugal 433';
@override
String get portugal868 => 'Portugal 868';
@override
String get czechRepublicNarrow => 'República Checa (Estreito)';
@override
String get eu433mhzLongRange => 'UE 433MHz (Longo Alcance)';
@override
String get euukDeprecated => 'UE/RU (Obsoleto)';
@override
String get euukNarrow => 'UE/RU (Estreito)';
@override
String get usacanadaRecommended => 'EUA/Canadá (Recomendado)';
@override
String get vietnamDeprecated => 'Vietname (Obsoleto)';
@override
String get vietnamNarrow => 'Vietname (Estreito)';
@override
String get active => 'Ativo';
@override
String get addContact => 'Adicionar contacto';
@override
String get all => 'Tudo';
@override
String get autoResolve => 'Resolver automaticamente';
@override
String get clearAllLabel => 'Limpar tudo';
@override
String get clearRelays => 'Limpar relays';
@override
String get clearFilters => 'Limpar filtros';
@override
String get clearRoute => 'Limpar rota';
@override
String get clearMessages => 'Limpar mensagens';
@override
String get clearScale => 'Limpar escala';
@override
String get clearDiscoveries => 'Limpar descobertas';
@override
String get clearOnlineTraceDatabase => 'Limpar base de dados de rastreio';
@override
String get clearAllChannels => 'Limpar todos os canais';
@override
String get clearAllContacts => 'Limpar todos os contactos';
@override
String get clearChannels => 'Limpar canais';
@override
String get clearContacts => 'Limpar contactos';
@override
String get clearPathOnMaxRetry => 'Limpar caminho no máx. de tentativas';
@override
String get create => 'Criar';
@override
String get custom => 'Personalizado';
@override
String get defaultValue => 'Padrão';
@override
String get duplicate => 'Duplicar';
@override
String get editName => 'Editar nome';
@override
String get open => 'Abrir';
@override
String get paste => 'Colar';
@override
String get preview => 'Pré-visualização';
@override
String get remove => 'Remover';
@override
String get rename => 'Renomear';
@override
String get resolveAll => 'Resolver tudo';
@override
String get send => 'Enviar';
@override
String get sendAnyway => 'Enviar mesmo assim';
@override
String get share => 'Partilhar';
@override
String get shareContact => 'Partilhar contacto';
@override
String get trace => 'Rastreio';
@override
String get use => 'Usar';
@override
String get useSelectedFrequency => 'Usar frequência selecionada';
@override
String get discovery => 'Descoberta';
@override
String get discoverRepeaters => 'Descobrir repetidores';
@override
String get discoverSensors => 'Descobrir sensores';
@override
String get repeaterDiscoverySent => 'Descoberta de repetidores enviada';
@override
String get sensorDiscoverySent => 'Descoberta de sensores enviada';
@override
String get clearedPendingDiscoveries => 'Descobertas pendentes limpas.';
@override
String get autoDiscovery => 'Descoberta automática';
@override
String get enableAutomaticAdding => 'Ativar adição automática';
@override
String get autoaddRepeaters => 'Adicionar repetidores automaticamente';
@override
String get autoaddRoomServers =>
'Adicionar servidores de sala automaticamente';
@override
String get autoaddSensors => 'Adicionar sensores automaticamente';
@override
String get autoaddUsers => 'Adicionar utilizadores automaticamente';
@override
String get overwriteOldestWhenFull =>
'Sobrescrever mais antigos quando cheio';
@override
String get storage => 'Armazenamento';
@override
String get dangerZone => 'Zona de perigo';
@override
String get profiles => 'Perfis';
@override
String get favourites => 'Favoritos';
@override
String get sensors => 'Sensores';
@override
String get others => 'Outros';
@override
String get gpsModule => 'Módulo GPS';
@override
String get liveTraffic => 'Tráfego ao vivo';
@override
String get repeatersMap => 'Mapa de repetidores';
@override
String get spectrumScan => 'Varredura de espectro';
@override
String get blePacketLogs => 'Registos de pacotes BLE';
@override
String get onlineTraceDatabase => 'Base de dados de rastreio';
@override
String get routePathByteSize => 'Tamanho do caminho em bytes';
@override
String get messageNotifications => 'Notificações de mensagens';
@override
String get sarAlerts => 'Alertas SAR';
@override
String get discoveryNotifications => 'Notificações de descoberta';
@override
String get updateNotifications => 'Notificações de atualização';
@override
String get muteWhileAppIsOpen => 'Silenciar com a app aberta';
@override
String get disableContacts => 'Desativar contactos';
@override
String get enableSensorsTab => 'Ativar separador Sensores';
@override
String get enableProfiles => 'Ativar perfis';
@override
String get autoRouteRotation => 'Rotação automática de rota';
@override
String get nearestRepeaterFallback => 'Repetidor mais próximo como reserva';
@override
String get deleteAllStoredMessageHistory =>
'Apagar todo o histórico de mensagens';
@override
String get messageFontSize => 'Tamanho da fonte de mensagens';
@override
String get rotateMapWithHeading => 'Rodar mapa com direção';
@override
String get showMapDebugInfo => 'Mostrar info de depuração do mapa';
@override
String get openMapInFullscreen => 'Abrir mapa em ecrã inteiro';
@override
String get showSarMarkersLabel => 'Mostrar marcadores SAR';
@override
String get displaySarMarkersOnTheMainMap =>
'Mostrar marcadores SAR no mapa principal';
@override
String get showAllContactTrailsLabel =>
'Mostrar todos os rastos de contactos';
@override
String get hideRepeatersOnMap => 'Ocultar repetidores no mapa';
@override
String get setMapScale => 'Definir escala do mapa';
@override
String get customMapScaleSaved => 'Escala de mapa personalizada guardada';
@override
String get voiceBitrate => 'Taxa de bits de voz';
@override
String get voiceCompressor => 'Compressor de voz';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Equilibra níveis de fala suaves e altos';
@override
String get voiceLimiter => 'Limitador de voz';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Previne corte de picos antes da codificação';
@override
String get micAutoGain => 'Ganho automático do microfone';
@override
String get letsTheRecorderAdjustInputLevel =>
'Permite ao gravador ajustar o nível de entrada';
@override
String get echoCancellation => 'Cancelamento de eco';
@override
String get noiseSuppression => 'Supressão de ruído';
@override
String get trimSilenceInVoiceMessages =>
'Cortar silêncio em mensagens de voz';
@override
String get compressor => 'Compressor';
@override
String get limiter => 'Limitador';
@override
String get autoGain => 'Ganho automático';
@override
String get echoCancel => 'Eco';
@override
String get noiseSuppress => 'Ruído';
@override
String get silenceTrim => 'Silêncio';
@override
String get maxImageSize => 'Tamanho máximo de imagem';
@override
String get imageCompression => 'Compressão de imagem';
@override
String get grayscale => 'Escala de cinzentos';
@override
String get ultraMode => 'Modo ultra';
@override
String get fastPrivateGpsUpdates => 'Atualizações GPS privadas rápidas';
@override
String get movementThreshold => 'Limiar de movimento';
@override
String get fastGpsMovementThreshold => 'Limiar de movimento GPS rápido';
@override
String get fastGpsActiveuseInterval => 'Intervalo de uso ativo GPS rápido';
@override
String get activeuseUpdateInterval => 'Intervalo de atualização de uso ativo';
@override
String get repeatNearbyTraffic => 'Repetir tráfego próximo';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Retransmitir através de repetidores na rede';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Apenas próximos, sem flood de repetidores';
@override
String get multihop => 'Multi-salto';
@override
String get createProfile => 'Criar perfil';
@override
String get renameProfile => 'Renomear perfil';
@override
String get newProfile => 'Novo perfil';
@override
String get manageProfiles => 'Gerir perfis';
@override
String get enableProfilesToStartManagingThem =>
'Ative os perfis para começar a geri-los.';
@override
String get openMessage => 'Abrir mensagem';
@override
String get jumpToTheRelatedSarMessage => 'Ir para a mensagem SAR relacionada';
@override
String get removeSarMarker => 'Remover marcador SAR';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Selecione um destino para enviar o marcador SAR';
@override
String get sarMarkerBroadcastToPublicChannel =>
'Marcador SAR transmitido no canal público';
@override
String get sarMarkerSentToRoom => 'Marcador SAR enviado para a sala';
@override
String get loadFromGallery => 'Carregar da galeria';
@override
String get replaceImage => 'Substituir imagem';
@override
String get selectFromGallery => 'Selecionar da galeria';
@override
String get team => 'Equipa';
@override
String get found => 'Encontrado';
@override
String get staging => 'Área de reunião';
@override
String get object => 'Objeto';
@override
String get quiet => 'Silencioso';
@override
String get moderate => 'Moderado';
@override
String get busy => 'Ocupado';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'A varredura de espectro não encontrou frequências candidatas';
@override
String get searchMessages => 'Pesquisar mensagens';
@override
String get sendImageFromGallery => 'Enviar imagem da galeria';
@override
String get takePhoto => 'Tirar foto';
@override
String get dmOnly => 'Apenas mensagem direta';
@override
String get allMessages => 'Todas as mensagens';
@override
String get sendToPublicChannel => 'Enviar para o canal público?';
@override
String get selectMarkerTypeAndDestination =>
'Selecione tipo de marcador e destino';
@override
String get noDestinationsAvailableLabel => 'Nenhum destino disponível';
@override
String get image => 'Imagem';
@override
String get format => 'Formato';
@override
String get dimensions => 'Dimensões';
@override
String get segments => 'Segmentos';
@override
String get transfers => 'Transferências';
@override
String get downloadedBy => 'Transferido por';
@override
String get saveDiscoverySettings => 'Guardar definições de descoberta';
@override
String get savePublicInfo => 'Guardar informação pública';
@override
String get saveRadioSettings => 'Guardar definições de rádio';
@override
String get savePath => 'Guardar caminho';
@override
String get wipeDeviceData => 'Apagar dados do dispositivo';
@override
String get wipeDevice => 'Apagar dispositivo';
@override
String get destructiveDeviceActions => 'Ações destrutivas do dispositivo.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Escolha uma predefinição ou ajuste as definições de rádio.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Escolha o nome e a localização que este dispositivo partilha.';
@override
String get availableSpaceOnThisDevice =>
'Espaço disponível neste dispositivo.';
@override
String get used => 'Usado';
@override
String get total => 'Total';
@override
String get renameValue => 'Renomear valor';
@override
String get customizeFields => 'Personalizar campos';
@override
String get livePreview => 'Pré-visualização ao vivo';
@override
String get refreshSchedule => 'Agenda de atualização';
@override
String get noResponse => 'Sem resposta';
@override
String get refreshing => 'A atualizar';
@override
String get unavailable => 'Indisponível';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Escolha um relay ou nó para observar.';
@override
String get publicKeyLabel => 'Chave pública';
@override
String get alreadyInContacts => 'Já nos contactos';
@override
String get connectToADeviceBeforeAddingContacts =>
'Conecte-se a um dispositivo antes de adicionar contactos';
@override
String get fromContacts => 'Dos contactos';
@override
String get onlineOnly => 'Apenas online';
@override
String get inBoth => 'Em ambos';
@override
String get source => 'Fonte';
@override
String get manualRouteEdit => 'Edição manual de rota';
@override
String get observedMeshRoute => 'Rota mesh observada';
@override
String get allMessagesCleared => 'Todas as mensagens limpas';
@override
String get onlineTraceDatabaseCleared => 'Base de dados de rastreio limpa';
@override
String get packetLogsCleared => 'Registos de pacotes limpos';
@override
String get hexDataCopiedToClipboard =>
'Dados hex copiados para a área de transferência';
@override
String get developerModeEnabled => 'Modo de programador ativado';
@override
String get developerModeDisabled => 'Modo de programador desativado';
@override
String get clipboardIsEmpty => 'A área de transferência está vazia';
@override
String get contactImported => 'Contacto importado';
@override
String get contactLinkCopiedToClipboard =>
'Link do contacto copiado para a área de transferência';
@override
String get failedToExportContact => 'Falha ao exportar contacto';
@override
String get noLogsToExport => 'Sem registos para exportar';
@override
String get exportAsCsv => 'Exportar como CSV';
@override
String get exportAsText => 'Exportar como texto';
@override
String get receivedRfc3339 => 'Recebido (RFC3339)';
@override
String get buildTime => 'Hora de compilação';
@override
String get downloadUrlNotAvailable => 'URL de download não disponível';
@override
String get cannotOpenDownloadUrl => 'Não é possível abrir o URL de download';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'A verificação de atualizações só está disponível no Android';
@override
String get youAreRunningTheLatestVersion =>
'Está a usar a versão mais recente';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Atualização disponível mas URL de download não encontrado';
@override
String get startTictactoe => 'Iniciar Tic-Tac-Toe';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe indisponível';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: oponente desconhecido';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: a aguardar início';
@override
String get acceptsShareLinks => 'Aceita links partilhados';
@override
String get supportsRawHex => 'Suporta hex bruto';
@override
String get clipboardfriendly => 'Compatível com a área de transferência';
@override
String get captured => 'Capturado';
@override
String get size => 'Tamanho';
@override
String get noCustomChannelsToClear =>
'Sem canais personalizados para limpar.';
@override
String get noDeviceContactsToClear =>
'Sem contactos do dispositivo para limpar.';
}

View File

@@ -1921,4 +1921,768 @@ class AppLocalizationsRu extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Не удалось задать маршрут: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'Таймаут ACK';
@override
String get opcode => 'Опкод';
@override
String get payload => 'Полезная нагрузка';
@override
String get hops => 'Хопы';
@override
String get hashSize => 'Размер хеша';
@override
String get pathBytes => 'Байты пути';
@override
String get selectedPath => 'Выбранный путь';
@override
String get estimatedTx => 'Ожидаемая передача';
@override
String get senderToReceipt => 'От отправителя до получения';
@override
String get receivedCopies => 'Полученные копии';
@override
String get retryCause => 'Причина повтора';
@override
String get retryMode => 'Режим повтора';
@override
String get retryResult => 'Результат повтора';
@override
String get lastRetry => 'Последний повтор';
@override
String get rxPackets => 'RX-пакеты';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Скорость';
@override
String get window => 'Окно';
@override
String get posttxDelay => 'Задержка после передачи';
@override
String get bandpass => 'Полосовой';
@override
String get bandpassFilterVoice => 'Полосовой фильтр голоса';
@override
String get frequency => 'Частота';
@override
String get australia => 'Австралия';
@override
String get australiaNarrow => 'Австралия (Узкий)';
@override
String get australiaQld => 'Австралия: QLD';
@override
String get australiaSaWa => 'Австралия: SA, WA';
@override
String get newZealand => 'Новая Зеландия';
@override
String get newZealandNarrow => 'Новая Зеландия (Узкий)';
@override
String get switzerland => 'Швейцария';
@override
String get portugal433 => 'Португалия 433';
@override
String get portugal868 => 'Португалия 868';
@override
String get czechRepublicNarrow => 'Чехия (Узкий)';
@override
String get eu433mhzLongRange => 'ЕС 433МГц (Дальний)';
@override
String get euukDeprecated => 'ЕС/Великобритания (Устарело)';
@override
String get euukNarrow => 'ЕС/Великобритания (Узкий)';
@override
String get usacanadaRecommended => 'США/Канада (Рекомендуется)';
@override
String get vietnamDeprecated => 'Вьетнам (Устарело)';
@override
String get vietnamNarrow => 'Вьетнам (Узкий)';
@override
String get active => 'Активно';
@override
String get addContact => 'Добавить контакт';
@override
String get all => 'Все';
@override
String get autoResolve => 'Автоматическое разрешение';
@override
String get clearAllLabel => 'Очистить все';
@override
String get clearRelays => 'Очистить реле';
@override
String get clearFilters => 'Очистить фильтры';
@override
String get clearRoute => 'Очистить маршрут';
@override
String get clearMessages => 'Очистить сообщения';
@override
String get clearScale => 'Очистить масштаб';
@override
String get clearDiscoveries => 'Очистить обнаружения';
@override
String get clearOnlineTraceDatabase => 'Очистить базу трассировок';
@override
String get clearAllChannels => 'Очистить все каналы';
@override
String get clearAllContacts => 'Очистить все контакты';
@override
String get clearChannels => 'Очистить каналы';
@override
String get clearContacts => 'Очистить контакты';
@override
String get clearPathOnMaxRetry => 'Очистить путь при макс. повторе';
@override
String get create => 'Создать';
@override
String get custom => 'Пользовательский';
@override
String get defaultValue => 'По умолчанию';
@override
String get duplicate => 'Дублировать';
@override
String get editName => 'Редактировать имя';
@override
String get open => 'Открыть';
@override
String get paste => 'Вставить';
@override
String get preview => 'Предпросмотр';
@override
String get remove => 'Удалить';
@override
String get rename => 'Переименовать';
@override
String get resolveAll => 'Разрешить все';
@override
String get send => 'Отправить';
@override
String get sendAnyway => 'Отправить все равно';
@override
String get share => 'Поделиться';
@override
String get shareContact => 'Поделиться контактом';
@override
String get trace => 'Трассировка';
@override
String get use => 'Использовать';
@override
String get useSelectedFrequency => 'Использовать выбранную частоту';
@override
String get discovery => 'Обнаружение';
@override
String get discoverRepeaters => 'Обнаружить повторители';
@override
String get discoverSensors => 'Обнаружить датчики';
@override
String get repeaterDiscoverySent => 'Обнаружение повторителей отправлено';
@override
String get sensorDiscoverySent => 'Обнаружение датчиков отправлено';
@override
String get clearedPendingDiscoveries => 'Ожидающие обнаружения очищены.';
@override
String get autoDiscovery => 'Автоматическое обнаружение';
@override
String get enableAutomaticAdding => 'Включить автоматическое добавление';
@override
String get autoaddRepeaters => 'Автодобавление повторителей';
@override
String get autoaddRoomServers => 'Автодобавление серверов комнат';
@override
String get autoaddSensors => 'Автодобавление датчиков';
@override
String get autoaddUsers => 'Автодобавление пользователей';
@override
String get overwriteOldestWhenFull => 'Перезаписать старейшие при заполнении';
@override
String get storage => 'Хранилище';
@override
String get dangerZone => 'Опасная зона';
@override
String get profiles => 'Профили';
@override
String get favourites => 'Избранное';
@override
String get sensors => 'Датчики';
@override
String get others => 'Другие';
@override
String get gpsModule => 'Модуль GPS';
@override
String get liveTraffic => 'Живой трафик';
@override
String get repeatersMap => 'Карта повторителей';
@override
String get spectrumScan => 'Сканирование спектра';
@override
String get blePacketLogs => 'Журналы BLE-пакетов';
@override
String get onlineTraceDatabase => 'База трассировок';
@override
String get routePathByteSize => 'Размер пути в байтах';
@override
String get messageNotifications => 'Уведомления о сообщениях';
@override
String get sarAlerts => 'SAR-оповещения';
@override
String get discoveryNotifications => 'Уведомления об обнаружении';
@override
String get updateNotifications => 'Уведомления об обновлениях';
@override
String get muteWhileAppIsOpen => 'Без звука при открытом приложении';
@override
String get disableContacts => 'Отключить контакты';
@override
String get enableSensorsTab => 'Включить вкладку Датчики';
@override
String get enableProfiles => 'Включить профили';
@override
String get autoRouteRotation => 'Автоматическая ротация маршрута';
@override
String get nearestRepeaterFallback => 'Ближайший повторитель как резерв';
@override
String get deleteAllStoredMessageHistory => 'Удалить всю историю сообщений';
@override
String get messageFontSize => 'Размер шрифта сообщений';
@override
String get rotateMapWithHeading => 'Вращать карту по направлению';
@override
String get showMapDebugInfo => 'Показать отладочную информацию карты';
@override
String get openMapInFullscreen => 'Открыть карту на весь экран';
@override
String get showSarMarkersLabel => 'Показать SAR-маркеры';
@override
String get displaySarMarkersOnTheMainMap =>
'Отображать SAR-маркеры на основной карте';
@override
String get showAllContactTrailsLabel => 'Показать все следы контактов';
@override
String get hideRepeatersOnMap => 'Скрыть повторители на карте';
@override
String get setMapScale => 'Установить масштаб карты';
@override
String get customMapScaleSaved => 'Пользовательский масштаб карты сохранён';
@override
String get voiceBitrate => 'Битрейт голоса';
@override
String get voiceCompressor => 'Компрессор голоса';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Выравнивает тихую и громкую речь';
@override
String get voiceLimiter => 'Лимитер голоса';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Предотвращает обрезку пиков перед кодированием';
@override
String get micAutoGain => 'Автоусиление микрофона';
@override
String get letsTheRecorderAdjustInputLevel =>
'Позволяет рекордеру регулировать уровень входа';
@override
String get echoCancellation => 'Подавление эха';
@override
String get noiseSuppression => 'Подавление шума';
@override
String get trimSilenceInVoiceMessages =>
'Обрезать тишину в голосовых сообщениях';
@override
String get compressor => 'Компрессор';
@override
String get limiter => 'Лимитер';
@override
String get autoGain => 'Автоусиление';
@override
String get echoCancel => 'Эхо';
@override
String get noiseSuppress => 'Шум';
@override
String get silenceTrim => 'Тишина';
@override
String get maxImageSize => 'Максимальный размер изображения';
@override
String get imageCompression => 'Сжатие изображения';
@override
String get grayscale => 'Оттенки серого';
@override
String get ultraMode => 'Режим ультра';
@override
String get fastPrivateGpsUpdates => 'Быстрые приватные GPS-обновления';
@override
String get movementThreshold => 'Порог движения';
@override
String get fastGpsMovementThreshold => 'Порог движения быстрого GPS';
@override
String get fastGpsActiveuseInterval =>
'Интервал активного использования быстрого GPS';
@override
String get activeuseUpdateInterval =>
'Интервал обновления при активном использовании';
@override
String get repeatNearbyTraffic => 'Повторять ближний трафик';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Ретранслировать через повторители по сети';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Только рядом, без flood повторителей';
@override
String get multihop => 'Многохоповый';
@override
String get createProfile => 'Создать профиль';
@override
String get renameProfile => 'Переименовать профиль';
@override
String get newProfile => 'Новый профиль';
@override
String get manageProfiles => 'Управление профилями';
@override
String get enableProfilesToStartManagingThem =>
'Включите профили, чтобы начать управлять ими.';
@override
String get openMessage => 'Открыть сообщение';
@override
String get jumpToTheRelatedSarMessage => 'Перейти к связанному SAR-сообщению';
@override
String get removeSarMarker => 'Удалить SAR-маркер';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Выберите назначение для отправки SAR-маркера';
@override
String get sarMarkerBroadcastToPublicChannel =>
'SAR-маркер отправлен в публичный канал';
@override
String get sarMarkerSentToRoom => 'SAR-маркер отправлен в комнату';
@override
String get loadFromGallery => 'Загрузить из галереи';
@override
String get replaceImage => 'Заменить изображение';
@override
String get selectFromGallery => 'Выбрать из галереи';
@override
String get team => 'Команда';
@override
String get found => 'Найдено';
@override
String get staging => 'Место сбора';
@override
String get object => 'Объект';
@override
String get quiet => 'Тихо';
@override
String get moderate => 'Умеренно';
@override
String get busy => 'Занято';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'Сканирование спектра не нашло частот-кандидатов';
@override
String get searchMessages => 'Поиск сообщений';
@override
String get sendImageFromGallery => 'Отправить изображение из галереи';
@override
String get takePhoto => 'Сделать фото';
@override
String get dmOnly => 'Только личные сообщения';
@override
String get allMessages => 'Все сообщения';
@override
String get sendToPublicChannel => 'Отправить в публичный канал?';
@override
String get selectMarkerTypeAndDestination =>
'Выберите тип маркера и назначение';
@override
String get noDestinationsAvailableLabel => 'Нет доступных назначений';
@override
String get image => 'Изображение';
@override
String get format => 'Формат';
@override
String get dimensions => 'Размеры';
@override
String get segments => 'Сегменты';
@override
String get transfers => 'Передачи';
@override
String get downloadedBy => 'Загружено';
@override
String get saveDiscoverySettings => 'Сохранить настройки обнаружения';
@override
String get savePublicInfo => 'Сохранить публичную информацию';
@override
String get saveRadioSettings => 'Сохранить настройки радио';
@override
String get savePath => 'Сохранить путь';
@override
String get wipeDeviceData => 'Стереть данные устройства';
@override
String get wipeDevice => 'Стереть устройство';
@override
String get destructiveDeviceActions => 'Деструктивные действия устройства.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Выберите пресет или настройте радио вручную.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Выберите имя и местоположение, которыми делится устройство.';
@override
String get availableSpaceOnThisDevice =>
'Доступное место на этом устройстве.';
@override
String get used => 'Использовано';
@override
String get total => 'Всего';
@override
String get renameValue => 'Переименовать значение';
@override
String get customizeFields => 'Настроить поля';
@override
String get livePreview => 'Предпросмотр в реальном времени';
@override
String get refreshSchedule => 'Расписание обновления';
@override
String get noResponse => 'Нет ответа';
@override
String get refreshing => 'Обновление';
@override
String get unavailable => 'Недоступно';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Выберите реле или узел для наблюдения.';
@override
String get publicKeyLabel => 'Публичный ключ';
@override
String get alreadyInContacts => 'Уже в контактах';
@override
String get connectToADeviceBeforeAddingContacts =>
'Подключитесь к устройству перед добавлением контактов';
@override
String get fromContacts => 'Из контактов';
@override
String get onlineOnly => 'Только онлайн';
@override
String get inBoth => 'В обоих';
@override
String get source => 'Источник';
@override
String get manualRouteEdit => 'Ручное редактирование маршрута';
@override
String get observedMeshRoute => 'Наблюдаемый mesh-маршрут';
@override
String get allMessagesCleared => 'Все сообщения очищены';
@override
String get onlineTraceDatabaseCleared => 'База трассировок очищена';
@override
String get packetLogsCleared => 'Журналы пакетов очищены';
@override
String get hexDataCopiedToClipboard => 'Hex-данные скопированы в буфер';
@override
String get developerModeEnabled => 'Режим разработчика включён';
@override
String get developerModeDisabled => 'Режим разработчика отключён';
@override
String get clipboardIsEmpty => 'Буфер обмена пуст';
@override
String get contactImported => 'Контакт импортирован';
@override
String get contactLinkCopiedToClipboard =>
'Ссылка на контакт скопирована в буфер';
@override
String get failedToExportContact => 'Не удалось экспортировать контакт';
@override
String get noLogsToExport => 'Нет журналов для экспорта';
@override
String get exportAsCsv => 'Экспортировать как CSV';
@override
String get exportAsText => 'Экспортировать как текст';
@override
String get receivedRfc3339 => 'Получено (RFC3339)';
@override
String get buildTime => 'Время сборки';
@override
String get downloadUrlNotAvailable => 'URL загрузки недоступен';
@override
String get cannotOpenDownloadUrl => 'Не удаётся открыть URL загрузки';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'Проверка обновлений доступна только на Android';
@override
String get youAreRunningTheLatestVersion => 'Вы используете последнюю версию';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Обновление доступно, но URL загрузки не найден';
@override
String get startTictactoe => 'Начать Tic-Tac-Toe';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe недоступно';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: противник неизвестен';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: ожидание начала';
@override
String get acceptsShareLinks => 'Принимает общие ссылки';
@override
String get supportsRawHex => 'Поддерживает raw hex';
@override
String get clipboardfriendly => 'Удобно для буфера';
@override
String get captured => 'Захвачено';
@override
String get size => 'Размер';
@override
String get noCustomChannelsToClear =>
'Нет пользовательских каналов для очистки.';
@override
String get noDeviceContactsToClear => 'Нет контактов устройства для очистки.';
}

View File

@@ -1912,4 +1912,767 @@ class AppLocalizationsSl extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Nastavitev poti ni uspela: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'Časovna omejitev ACK';
@override
String get opcode => 'Opkoda';
@override
String get payload => 'Vsebina';
@override
String get hops => 'Skoki';
@override
String get hashSize => 'Velikost zgoščevalne vrednosti';
@override
String get pathBytes => 'Bajti poti';
@override
String get selectedPath => 'Izbrana pot';
@override
String get estimatedTx => 'Ocenjeni čas oddaje';
@override
String get senderToReceipt => 'Od pošiljatelja do prejema';
@override
String get receivedCopies => 'Prejete kopije';
@override
String get retryCause => 'Razlog ponovitve';
@override
String get retryMode => 'Način ponovitve';
@override
String get retryResult => 'Rezultat ponovitve';
@override
String get lastRetry => 'Zadnja ponovitev';
@override
String get rxPackets => 'RX paketi';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Hitrost';
@override
String get window => 'Okno';
@override
String get posttxDelay => 'Zakasnitev po oddaji';
@override
String get bandpass => 'Pasovno sito';
@override
String get bandpassFilterVoice => 'Pasovno sito za glas';
@override
String get frequency => 'Frekvenca';
@override
String get australia => 'Avstralija';
@override
String get australiaNarrow => 'Avstralija (ozko)';
@override
String get australiaQld => 'Avstralija: QLD';
@override
String get australiaSaWa => 'Avstralija: SA, WA';
@override
String get newZealand => 'Nova Zelandija';
@override
String get newZealandNarrow => 'Nova Zelandija (ozko)';
@override
String get switzerland => 'Švica';
@override
String get portugal433 => 'Portugalska 433';
@override
String get portugal868 => 'Portugalska 868';
@override
String get czechRepublicNarrow => 'Češka (ozko)';
@override
String get eu433mhzLongRange => 'EU 433MHz (dolg doseg)';
@override
String get euukDeprecated => 'EU/UK (zastarelo)';
@override
String get euukNarrow => 'EU/UK (ozko)';
@override
String get usacanadaRecommended => 'ZDA/Kanada (priporočeno)';
@override
String get vietnamDeprecated => 'Vietnam (zastarelo)';
@override
String get vietnamNarrow => 'Vietnam (ozko)';
@override
String get active => 'Aktivno';
@override
String get addContact => 'Dodaj stik';
@override
String get all => 'Vse';
@override
String get autoResolve => 'Samodejno razreši';
@override
String get clearAllLabel => 'Počisti vse';
@override
String get clearRelays => 'Počisti posrednike';
@override
String get clearFilters => 'Počisti filtre';
@override
String get clearRoute => 'Počisti pot';
@override
String get clearMessages => 'Počisti sporočila';
@override
String get clearScale => 'Počisti merilo';
@override
String get clearDiscoveries => 'Počisti odkritja';
@override
String get clearOnlineTraceDatabase => 'Počisti bazo sledi';
@override
String get clearAllChannels => 'Počisti vse kanale';
@override
String get clearAllContacts => 'Počisti vse stike';
@override
String get clearChannels => 'Počisti kanale';
@override
String get clearContacts => 'Počisti stike';
@override
String get clearPathOnMaxRetry => 'Počisti pot ob maks. ponovitvi';
@override
String get create => 'Ustvari';
@override
String get custom => 'Po meri';
@override
String get defaultValue => 'Privzeto';
@override
String get duplicate => 'Podvoji';
@override
String get editName => 'Uredi ime';
@override
String get open => 'Odpri';
@override
String get paste => 'Prilepi';
@override
String get preview => 'Predogled';
@override
String get remove => 'Odstrani';
@override
String get rename => 'Preimenuj';
@override
String get resolveAll => 'Razreši vse';
@override
String get send => 'Pošlji';
@override
String get sendAnyway => 'Vseeno pošlji';
@override
String get share => 'Deli';
@override
String get shareContact => 'Deli stik';
@override
String get trace => 'Sledenje';
@override
String get use => 'Uporabi';
@override
String get useSelectedFrequency => 'Uporabi izbrano frekvenco';
@override
String get discovery => 'Odkrivanje';
@override
String get discoverRepeaters => 'Odkrij posrednike';
@override
String get discoverSensors => 'Odkrij senzorje';
@override
String get repeaterDiscoverySent => 'Odkrivanje posrednikov poslano';
@override
String get sensorDiscoverySent => 'Odkrivanje senzorjev poslano';
@override
String get clearedPendingDiscoveries => 'Čakalna odkritja počiščena.';
@override
String get autoDiscovery => 'Samodejno odkrivanje';
@override
String get enableAutomaticAdding => 'Omogoči samodejno dodajanje';
@override
String get autoaddRepeaters => 'Samodejno dodaj posrednike';
@override
String get autoaddRoomServers => 'Samodejno dodaj strežnike sob';
@override
String get autoaddSensors => 'Samodejno dodaj senzorje';
@override
String get autoaddUsers => 'Samodejno dodaj uporabnike';
@override
String get overwriteOldestWhenFull => 'Prepiši najstarejše ob polnem';
@override
String get storage => 'Shramba';
@override
String get dangerZone => 'Nevarno območje';
@override
String get profiles => 'Profili';
@override
String get favourites => 'Priljubljeni';
@override
String get sensors => 'Senzorji';
@override
String get others => 'Ostali';
@override
String get gpsModule => 'GPS modul';
@override
String get liveTraffic => 'Promet v živo';
@override
String get repeatersMap => 'Zemljevid posrednikov';
@override
String get spectrumScan => 'Spektralno skeniranje';
@override
String get blePacketLogs => 'Dnevniki BLE paketov';
@override
String get onlineTraceDatabase => 'Baza sledi';
@override
String get routePathByteSize => 'Velikost poti v bajtih';
@override
String get messageNotifications => 'Obvestila o sporočilih';
@override
String get sarAlerts => 'SAR opozorila';
@override
String get discoveryNotifications => 'Obvestila o odkritjih';
@override
String get updateNotifications => 'Obvestila o posodobitvah';
@override
String get muteWhileAppIsOpen => 'Utišaj ko je aplikacija odprta';
@override
String get disableContacts => 'Onemogoči stike';
@override
String get enableSensorsTab => 'Omogoči zavihek Senzorji';
@override
String get enableProfiles => 'Omogoči profile';
@override
String get autoRouteRotation => 'Samodejno kroženje poti';
@override
String get nearestRepeaterFallback => 'Najbližji posrednik kot rezerva';
@override
String get deleteAllStoredMessageHistory =>
'Izbriši vso shranjeno zgodovino sporočil';
@override
String get messageFontSize => 'Velikost pisave sporočil';
@override
String get rotateMapWithHeading => 'Vrti zemljevid s smerjo';
@override
String get showMapDebugInfo => 'Prikaži razhroščevalne info zemljevida';
@override
String get openMapInFullscreen => 'Odpri zemljevid na cel zaslon';
@override
String get showSarMarkersLabel => 'Prikaži SAR oznake';
@override
String get displaySarMarkersOnTheMainMap =>
'Prikaži SAR oznake na glavnem zemljevidu';
@override
String get showAllContactTrailsLabel => 'Prikaži vse sledi stikov';
@override
String get hideRepeatersOnMap => 'Skrij posrednike na zemljevidu';
@override
String get setMapScale => 'Nastavi merilo zemljevida';
@override
String get customMapScaleSaved => 'Merilo zemljevida shranjeno';
@override
String get voiceBitrate => 'Bitna hitrost glasu';
@override
String get voiceCompressor => 'Kompresor glasu';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Izravnava tihe in glasne govorne ravni';
@override
String get voiceLimiter => 'Omejevalnik glasu';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Preprečuje rezanje vrhov pred kodiranjem';
@override
String get micAutoGain => 'Samodejna ojačitev mikrofona';
@override
String get letsTheRecorderAdjustInputLevel =>
'Omogoči snemalniku prilagoditev vhodne ravni';
@override
String get echoCancellation => 'Odstranjevanje odmeva';
@override
String get noiseSuppression => 'Dušenje šuma';
@override
String get trimSilenceInVoiceMessages =>
'Obreži tišino v glasovnih sporočilih';
@override
String get compressor => 'Kompresor';
@override
String get limiter => 'Omejevalnik';
@override
String get autoGain => 'Samodejna ojačitev';
@override
String get echoCancel => 'Odmev';
@override
String get noiseSuppress => 'Šum';
@override
String get silenceTrim => 'Tišina';
@override
String get maxImageSize => 'Največja velikost slike';
@override
String get imageCompression => 'Stiskanje slik';
@override
String get grayscale => 'Sivine';
@override
String get ultraMode => 'Ultra način';
@override
String get fastPrivateGpsUpdates => 'Hitri zasebni GPS podatki';
@override
String get movementThreshold => 'Prag gibanja';
@override
String get fastGpsMovementThreshold => 'Prag gibanja za hitri GPS';
@override
String get fastGpsActiveuseInterval => 'Interval uporabe hitrega GPS-a';
@override
String get activeuseUpdateInterval =>
'Interval posodobitve pri aktivni uporabi';
@override
String get repeatNearbyTraffic => 'Ponavljaj bližnji promet';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Posreduj skozi posrednike po mreži';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Samo bližnji, brez poplavljanja posrednikov';
@override
String get multihop => 'Večskočno';
@override
String get createProfile => 'Ustvari profil';
@override
String get renameProfile => 'Preimenuj profil';
@override
String get newProfile => 'Nov profil';
@override
String get manageProfiles => 'Upravljaj profile';
@override
String get enableProfilesToStartManagingThem =>
'Omogočite profile, da jih začnete upravljati.';
@override
String get openMessage => 'Odpri sporočilo';
@override
String get jumpToTheRelatedSarMessage => 'Skoči na povezano SAR sporočilo';
@override
String get removeSarMarker => 'Odstrani SAR oznako';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Izberite cilj za pošiljanje SAR oznake';
@override
String get sarMarkerBroadcastToPublicChannel =>
'SAR oznaka poslana na javni kanal';
@override
String get sarMarkerSentToRoom => 'SAR oznaka poslana v sobo';
@override
String get loadFromGallery => 'Naloži iz galerije';
@override
String get replaceImage => 'Zamenjaj sliko';
@override
String get selectFromGallery => 'Izberi iz galerije';
@override
String get team => 'Ekipa';
@override
String get found => 'Najdeno';
@override
String get staging => 'Zbirno mesto';
@override
String get object => 'Predmet';
@override
String get quiet => 'Tiho';
@override
String get moderate => 'Zmerno';
@override
String get busy => 'Zasedeno';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'Spektralno skeniranje ni našlo primernih frekvenc';
@override
String get searchMessages => 'Iskanje sporočil';
@override
String get sendImageFromGallery => 'Pošlji sliko iz galerije';
@override
String get takePhoto => 'Posnemi fotografijo';
@override
String get dmOnly => 'Samo neposredno';
@override
String get allMessages => 'Vsa sporočila';
@override
String get sendToPublicChannel => 'Pošlji na javni kanal?';
@override
String get selectMarkerTypeAndDestination => 'Izberite vrsto oznake in cilj';
@override
String get noDestinationsAvailableLabel => 'Ni razpoložljivih ciljev';
@override
String get image => 'Slika';
@override
String get format => 'Format';
@override
String get dimensions => 'Dimenzije';
@override
String get segments => 'Segmenti';
@override
String get transfers => 'Prenosi';
@override
String get downloadedBy => 'Preneseno od';
@override
String get saveDiscoverySettings => 'Shrani nastavitve odkrivanja';
@override
String get savePublicInfo => 'Shrani javne podatke';
@override
String get saveRadioSettings => 'Shrani radijske nastavitve';
@override
String get savePath => 'Shrani pot';
@override
String get wipeDeviceData => 'Izbriši podatke naprave';
@override
String get wipeDevice => 'Izbriši napravo';
@override
String get destructiveDeviceActions => 'Destruktivna dejanja na napravi.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Izberite prednastavitev ali natančno prilagodite radijske nastavitve.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Izberite ime in lokacijo, ki ju naprava deli.';
@override
String get availableSpaceOnThisDevice =>
'Razpoložljiv prostor na tej napravi.';
@override
String get used => 'Uporabljeno';
@override
String get total => 'Skupaj';
@override
String get renameValue => 'Preimenuj vrednost';
@override
String get customizeFields => 'Prilagodi polja';
@override
String get livePreview => 'Predogled v živo';
@override
String get refreshSchedule => 'Interval osvežitve';
@override
String get noResponse => 'Ni odgovora';
@override
String get refreshing => 'Osvežujem';
@override
String get unavailable => 'Nedostopno';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Izberite posrednika ali vozlišče za opazovanje.';
@override
String get publicKeyLabel => 'Javni ključ';
@override
String get alreadyInContacts => 'Že med stiki';
@override
String get connectToADeviceBeforeAddingContacts =>
'Povežite se z napravo pred dodajanjem stikov';
@override
String get fromContacts => 'Iz stikov';
@override
String get onlineOnly => 'Samo povezani';
@override
String get inBoth => 'V obeh';
@override
String get source => 'Vir';
@override
String get manualRouteEdit => 'Ročno urejanje poti';
@override
String get observedMeshRoute => 'Opazovana mrežna pot';
@override
String get allMessagesCleared => 'Vsa sporočila počiščena';
@override
String get onlineTraceDatabaseCleared => 'Baza sledi počiščena';
@override
String get packetLogsCleared => 'Dnevniki paketov počiščeni';
@override
String get hexDataCopiedToClipboard => 'Hex podatki kopirani v odložišče';
@override
String get developerModeEnabled => 'Razvojni način omogočen';
@override
String get developerModeDisabled => 'Razvojni način onemogočen';
@override
String get clipboardIsEmpty => 'Odložišče je prazno';
@override
String get contactImported => 'Stik uvožen';
@override
String get contactLinkCopiedToClipboard =>
'Povezava na stik kopirana v odložišče';
@override
String get failedToExportContact => 'Izvoz stika ni uspel';
@override
String get noLogsToExport => 'Ni dnevnikov za izvoz';
@override
String get exportAsCsv => 'Izvozi kot CSV';
@override
String get exportAsText => 'Izvozi kot besedilo';
@override
String get receivedRfc3339 => 'Prejeto (RFC3339)';
@override
String get buildTime => 'Čas gradnje';
@override
String get downloadUrlNotAvailable => 'URL za prenos ni na voljo';
@override
String get cannotOpenDownloadUrl => 'URL za prenos ni mogoče odpreti';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'Preverjanje posodobitev je na voljo le na Androidu';
@override
String get youAreRunningTheLatestVersion =>
'Uporabljate najnovejšo različico';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Posodobitev na voljo, a URL za prenos ni najden';
@override
String get startTictactoe => 'Začni Tic-Tac-Toe';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe ni na voljo';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: nasprotnik neznan';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: čakanje na začetek';
@override
String get acceptsShareLinks => 'Sprejema deljene povezave';
@override
String get supportsRawHex => 'Podpira surove hex';
@override
String get clipboardfriendly => 'Primerno za odložišče';
@override
String get captured => 'Zajeto';
@override
String get size => 'Velikost';
@override
String get noCustomChannelsToClear => 'Ni prilagojenih kanalov za brisanje.';
@override
String get noDeviceContactsToClear => 'Ni stikov naprave za brisanje.';
}

View File

@@ -1914,4 +1914,763 @@ class AppLocalizationsTr extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Rota ayarlanamadı: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'ACK zaman aşımı';
@override
String get opcode => 'Opcode';
@override
String get payload => 'Yük';
@override
String get hops => 'Atlama';
@override
String get hashSize => 'Hash boyutu';
@override
String get pathBytes => 'Yol baytları';
@override
String get selectedPath => 'Seçilen yol';
@override
String get estimatedTx => 'Tahmini gönderim';
@override
String get senderToReceipt => 'Gönderenden alındıya';
@override
String get receivedCopies => 'Alınan kopyalar';
@override
String get retryCause => 'Yeniden deneme nedeni';
@override
String get retryMode => 'Yeniden deneme modu';
@override
String get retryResult => 'Yeniden deneme sonucu';
@override
String get lastRetry => 'Son deneme';
@override
String get rxPackets => 'RX paketleri';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Hız';
@override
String get window => 'Pencere';
@override
String get posttxDelay => 'Gönderim sonrası gecikme';
@override
String get bandpass => 'Bant geçiren';
@override
String get bandpassFilterVoice => 'Bant geçiren filtre ses';
@override
String get frequency => 'Frekans';
@override
String get australia => 'Avustralya';
@override
String get australiaNarrow => 'Avustralya (Dar)';
@override
String get australiaQld => 'Avustralya: QLD';
@override
String get australiaSaWa => 'Avustralya: SA, WA';
@override
String get newZealand => 'Yeni Zelanda';
@override
String get newZealandNarrow => 'Yeni Zelanda (Dar)';
@override
String get switzerland => 'İsviçre';
@override
String get portugal433 => 'Portekiz 433';
@override
String get portugal868 => 'Portekiz 868';
@override
String get czechRepublicNarrow => 'Çek Cumhuriyeti (Dar)';
@override
String get eu433mhzLongRange => 'AB 433MHz (Uzun Menzil)';
@override
String get euukDeprecated => 'AB/BK (Kullanımdan kalktı)';
@override
String get euukNarrow => 'AB/BK (Dar)';
@override
String get usacanadaRecommended => 'ABD/Kanada (Önerilen)';
@override
String get vietnamDeprecated => 'Vietnam (Kullanımdan kalktı)';
@override
String get vietnamNarrow => 'Vietnam (Dar)';
@override
String get active => 'Aktif';
@override
String get addContact => 'Kişi ekle';
@override
String get all => 'Tümü';
@override
String get autoResolve => 'Otomatik çöz';
@override
String get clearAllLabel => 'Tümünü temizle';
@override
String get clearRelays => 'Röleleri temizle';
@override
String get clearFilters => 'Filtreleri temizle';
@override
String get clearRoute => 'Rotayı temizle';
@override
String get clearMessages => 'Mesajları temizle';
@override
String get clearScale => 'Ölçeği temizle';
@override
String get clearDiscoveries => 'Keşifleri temizle';
@override
String get clearOnlineTraceDatabase => 'Çevrimiçi iz veritabanını temizle';
@override
String get clearAllChannels => 'Tüm kanalları temizle';
@override
String get clearAllContacts => 'Tüm kişileri temizle';
@override
String get clearChannels => 'Kanalları temizle';
@override
String get clearContacts => 'Kişileri temizle';
@override
String get clearPathOnMaxRetry => 'Maks denemede yolu temizle';
@override
String get create => 'Oluştur';
@override
String get custom => 'Özel';
@override
String get defaultValue => 'Varsayılan';
@override
String get duplicate => 'Çoğalt';
@override
String get editName => 'Adı düzenle';
@override
String get open => '';
@override
String get paste => 'Yapıştır';
@override
String get preview => 'Önizleme';
@override
String get remove => 'Kaldır';
@override
String get rename => 'Yeniden adlandır';
@override
String get resolveAll => 'Tümünü çöz';
@override
String get send => 'Gönder';
@override
String get sendAnyway => 'Yine de gönder';
@override
String get share => 'Paylaş';
@override
String get shareContact => 'Kişiyi paylaş';
@override
String get trace => 'İz';
@override
String get use => 'Kullan';
@override
String get useSelectedFrequency => 'Seçilen frekansı kullan';
@override
String get discovery => 'Keşif';
@override
String get discoverRepeaters => 'Tekrarlayıcıları keşfet';
@override
String get discoverSensors => 'Sensörleri keşfet';
@override
String get repeaterDiscoverySent => 'Tekrarlayıcı keşfi gönderildi';
@override
String get sensorDiscoverySent => 'Sensör keşfi gönderildi';
@override
String get clearedPendingDiscoveries => 'Bekleyen keşifler temizlendi.';
@override
String get autoDiscovery => 'Otomatik keşif';
@override
String get enableAutomaticAdding => 'Otomatik eklemeyi etkinleştir';
@override
String get autoaddRepeaters => 'Tekrarlayıcıları otomatik ekle';
@override
String get autoaddRoomServers => 'Oda sunucularını otomatik ekle';
@override
String get autoaddSensors => 'Sensörleri otomatik ekle';
@override
String get autoaddUsers => 'Kullanıcıları otomatik ekle';
@override
String get overwriteOldestWhenFull => 'Dolduğunda en eskiyi üzerine yaz';
@override
String get storage => 'Depolama';
@override
String get dangerZone => 'Tehlikeli bölge';
@override
String get profiles => 'Profiller';
@override
String get favourites => 'Favoriler';
@override
String get sensors => 'Sensörler';
@override
String get others => 'Diğerleri';
@override
String get gpsModule => 'GPS Modülü';
@override
String get liveTraffic => 'Canlı trafik';
@override
String get repeatersMap => 'Tekrarlayıcılar haritası';
@override
String get spectrumScan => 'Spektrum taraması';
@override
String get blePacketLogs => 'BLE paket günlükleri';
@override
String get onlineTraceDatabase => 'Çevrimiçi iz veritabanı';
@override
String get routePathByteSize => 'Yol bayt boyutu';
@override
String get messageNotifications => 'Mesaj bildirimleri';
@override
String get sarAlerts => 'SAR uyarıları';
@override
String get discoveryNotifications => 'Keşif bildirimleri';
@override
String get updateNotifications => 'Güncelleme bildirimleri';
@override
String get muteWhileAppIsOpen => 'Uygulama açıkken sessize al';
@override
String get disableContacts => 'Kişileri devre dışı bırak';
@override
String get enableSensorsTab => 'Sensörler sekmesini etkinleştir';
@override
String get enableProfiles => 'Profilleri etkinleştir';
@override
String get autoRouteRotation => 'Otomatik rota rotasyonu';
@override
String get nearestRepeaterFallback => 'En yakın tekrarlayıcıya geri dönüş';
@override
String get deleteAllStoredMessageHistory => 'Tüm mesaj geçmişini sil';
@override
String get messageFontSize => 'Mesaj yazı tipi boyutu';
@override
String get rotateMapWithHeading => 'Haritayı yön ile döndür';
@override
String get showMapDebugInfo => 'Harita hata ayıklama bilgisini göster';
@override
String get openMapInFullscreen => 'Haritayı tam ekranda aç';
@override
String get showSarMarkersLabel => 'SAR işaretçilerini göster';
@override
String get displaySarMarkersOnTheMainMap =>
'SAR işaretçilerini ana haritada göster';
@override
String get showAllContactTrailsLabel => 'Tüm kişi izlerini göster';
@override
String get hideRepeatersOnMap => 'Haritada tekrarlayıcıları gizle';
@override
String get setMapScale => 'Harita ölçeğini ayarla';
@override
String get customMapScaleSaved => 'Özel harita ölçeği kaydedildi';
@override
String get voiceBitrate => 'Ses bit hızı';
@override
String get voiceCompressor => 'Ses sıkıştırıcı';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Sessiz ve yüksek konuşma seviyelerini dengeler';
@override
String get voiceLimiter => 'Ses sınırlayıcı';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Kodlama öncesi tepe kırpmasını önler';
@override
String get micAutoGain => 'Mikrofon otomatik kazancı';
@override
String get letsTheRecorderAdjustInputLevel =>
'Kaydedicinin giriş seviyesini ayarlamasına izin verir';
@override
String get echoCancellation => 'Yankı iptali';
@override
String get noiseSuppression => 'Gürültü bastırma';
@override
String get trimSilenceInVoiceMessages => 'Sesli mesajlarda sessizliği kırp';
@override
String get compressor => 'Sıkıştırıcı';
@override
String get limiter => 'Sınırlayıcı';
@override
String get autoGain => 'Otomatik kazanç';
@override
String get echoCancel => 'Yankı';
@override
String get noiseSuppress => 'Gürültü';
@override
String get silenceTrim => 'Sessizlik';
@override
String get maxImageSize => 'Maksimum resim boyutu';
@override
String get imageCompression => 'Resim sıkıştırma';
@override
String get grayscale => 'Gri tonlama';
@override
String get ultraMode => 'Ultra mod';
@override
String get fastPrivateGpsUpdates => 'Hızlı özel GPS güncellemeleri';
@override
String get movementThreshold => 'Hareket eşiği';
@override
String get fastGpsMovementThreshold => 'Hızlı GPS hareket eşiği';
@override
String get fastGpsActiveuseInterval => 'Hızlı GPS aktif kullanım aralığı';
@override
String get activeuseUpdateInterval => 'Aktif kullanım güncelleme aralığı';
@override
String get repeatNearbyTraffic => 'Yakın trafiği tekrarla';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Ağ üzerindeki tekrarlayıcılar aracılığıyla ilet';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Yalnızca yakın, tekrarlayıcı taşkını olmadan';
@override
String get multihop => 'Çok atlamalı';
@override
String get createProfile => 'Profil oluştur';
@override
String get renameProfile => 'Profili yeniden adlandır';
@override
String get newProfile => 'Yeni profil';
@override
String get manageProfiles => 'Profilleri yönet';
@override
String get enableProfilesToStartManagingThem =>
'Profilleri yönetmeye başlamak için etkinleştirin.';
@override
String get openMessage => 'Mesajı';
@override
String get jumpToTheRelatedSarMessage => 'İlgili SAR mesajına git';
@override
String get removeSarMarker => 'SAR işaretçisini kaldır';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'SAR işaretçisi göndermek için bir hedef seçin';
@override
String get sarMarkerBroadcastToPublicChannel =>
'SAR işaretçisi genel kanala yayınlandı';
@override
String get sarMarkerSentToRoom => 'SAR işaretçisi odaya gönderildi';
@override
String get loadFromGallery => 'Galeriden yükle';
@override
String get replaceImage => 'Resmi değiştir';
@override
String get selectFromGallery => 'Galeriden seç';
@override
String get team => 'Takım';
@override
String get found => 'Bulundu';
@override
String get staging => 'Toplanma alanı';
@override
String get object => 'Nesne';
@override
String get quiet => 'Sessiz';
@override
String get moderate => 'Orta';
@override
String get busy => 'Meşgul';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'Spektrum taraması aday frekans bulamadı';
@override
String get searchMessages => 'Mesajlarda ara';
@override
String get sendImageFromGallery => 'Galeriden resim gönder';
@override
String get takePhoto => 'Fotoğraf çek';
@override
String get dmOnly => 'Yalnızca DM';
@override
String get allMessages => 'Tüm mesajlar';
@override
String get sendToPublicChannel => 'Genel kanala gönderilsin mi?';
@override
String get selectMarkerTypeAndDestination =>
'İşaretçi türünü ve hedefi seçin';
@override
String get noDestinationsAvailableLabel => 'Kullanılabilir hedef yok';
@override
String get image => 'Resim';
@override
String get format => 'Biçim';
@override
String get dimensions => 'Boyutlar';
@override
String get segments => 'Segmentler';
@override
String get transfers => 'Transferler';
@override
String get downloadedBy => 'İndiren';
@override
String get saveDiscoverySettings => 'Keşif ayarlarını kaydet';
@override
String get savePublicInfo => 'Genel bilgileri kaydet';
@override
String get saveRadioSettings => 'Radyo ayarlarını kaydet';
@override
String get savePath => 'Yolu kaydet';
@override
String get wipeDeviceData => 'Cihaz verilerini sil';
@override
String get wipeDevice => 'Cihazı sil';
@override
String get destructiveDeviceActions => 'Yıkıcı cihaz işlemleri.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Bir ön ayar seçin veya radyo ayarlarını ince ayarlayın.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Bu cihazın paylaştığı ad ve konumu seçin.';
@override
String get availableSpaceOnThisDevice => 'Bu cihazda kullanılabilir alan.';
@override
String get used => 'Kullanılan';
@override
String get total => 'Toplam';
@override
String get renameValue => 'Değeri yeniden adlandır';
@override
String get customizeFields => 'Alanları özelleştir';
@override
String get livePreview => 'Canlı önizleme';
@override
String get refreshSchedule => 'Yenileme programı';
@override
String get noResponse => 'Yanıt yok';
@override
String get refreshing => 'Yenileniyor';
@override
String get unavailable => 'Kullanılamaz';
@override
String get pickARelayOrNodeToWatchInSensors =>
'İzlemek için bir röle veya düğüm seçin.';
@override
String get publicKeyLabel => 'Genel anahtar';
@override
String get alreadyInContacts => 'Zaten kişilerde';
@override
String get connectToADeviceBeforeAddingContacts =>
'Kişi eklemeden önce bir cihaza bağlanın';
@override
String get fromContacts => 'Kişilerden';
@override
String get onlineOnly => 'Yalnızca çevrimiçi';
@override
String get inBoth => 'Her ikisinde';
@override
String get source => 'Kaynak';
@override
String get manualRouteEdit => 'Manuel rota düzenle';
@override
String get observedMeshRoute => 'Gözlemlenen mesh rotası';
@override
String get allMessagesCleared => 'Tüm mesajlar temizlendi';
@override
String get onlineTraceDatabaseCleared => 'Çevrimiçi iz veritabanı temizlendi';
@override
String get packetLogsCleared => 'Paket günlükleri temizlendi';
@override
String get hexDataCopiedToClipboard => 'Hex verisi panoya kopyalandı';
@override
String get developerModeEnabled => 'Geliştirici modu etkinleştirildi';
@override
String get developerModeDisabled => 'Geliştirici modu devre dışı bırakıldı';
@override
String get clipboardIsEmpty => 'Pano boş';
@override
String get contactImported => 'Kişi içe aktarıldı';
@override
String get contactLinkCopiedToClipboard =>
'Kişi bağlantısı panoya kopyalandı';
@override
String get failedToExportContact => 'Kişi dışa aktarılamadı';
@override
String get noLogsToExport => 'Dışa aktarılacak günlük yok';
@override
String get exportAsCsv => 'CSV olarak dışa aktar';
@override
String get exportAsText => 'Metin olarak dışa aktar';
@override
String get receivedRfc3339 => 'Alındı (RFC3339)';
@override
String get buildTime => 'Derleme zamanı';
@override
String get downloadUrlNotAvailable => 'İndirme URL\'si mevcut değil';
@override
String get cannotOpenDownloadUrl => 'İndirme URL\'si açılamıyor';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'Güncelleme kontrolü yalnızca Android\'de kullanılabilir';
@override
String get youAreRunningTheLatestVersion => 'En son sürümü kullanıyorsunuz';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Güncelleme mevcut ancak indirme URL\'si bulunamadı';
@override
String get startTictactoe => 'Tic-Tac-Toe başlat';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe kullanılamıyor';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: rakip bilinmiyor';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: başlangıç bekleniyor';
@override
String get acceptsShareLinks => 'Paylaşım bağlantılarını kabul eder';
@override
String get supportsRawHex => 'Ham hex destekler';
@override
String get clipboardfriendly => 'Pano dostu';
@override
String get captured => 'Yakalandı';
@override
String get size => 'Boyut';
@override
String get noCustomChannelsToClear => 'Temizlenecek özel kanal yok.';
@override
String get noDeviceContactsToClear => 'Temizlenecek cihaz kişisi yok.';
}

View File

@@ -1927,4 +1927,773 @@ class AppLocalizationsUk extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return 'Не вдалося задати маршрут: $error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'Тайм-аут ACK';
@override
String get opcode => 'Опкод';
@override
String get payload => 'Корисне навантаження';
@override
String get hops => 'Хопи';
@override
String get hashSize => 'Розмір хешу';
@override
String get pathBytes => 'Байти шляху';
@override
String get selectedPath => 'Обраний шлях';
@override
String get estimatedTx => 'Очікувана передача';
@override
String get senderToReceipt => 'Від відправника до отримання';
@override
String get receivedCopies => 'Отримані копії';
@override
String get retryCause => 'Причина повтору';
@override
String get retryMode => 'Режим повтору';
@override
String get retryResult => 'Результат повтору';
@override
String get lastRetry => 'Остання спроба';
@override
String get rxPackets => 'RX-пакети';
@override
String get mesh => 'Mesh';
@override
String get rate => 'Швидкість';
@override
String get window => 'Вікно';
@override
String get posttxDelay => 'Затримка після передачі';
@override
String get bandpass => 'Смуговий';
@override
String get bandpassFilterVoice => 'Смуговий фільтр голосу';
@override
String get frequency => 'Частота';
@override
String get australia => 'Австралія';
@override
String get australiaNarrow => 'Австралія (Вузький)';
@override
String get australiaQld => 'Австралія: QLD';
@override
String get australiaSaWa => 'Австралія: SA, WA';
@override
String get newZealand => 'Нова Зеландія';
@override
String get newZealandNarrow => 'Нова Зеландія (Вузький)';
@override
String get switzerland => 'Швейцарія';
@override
String get portugal433 => 'Португалія 433';
@override
String get portugal868 => 'Португалія 868';
@override
String get czechRepublicNarrow => 'Чехія (Вузький)';
@override
String get eu433mhzLongRange => 'ЄС 433МГц (Далекий)';
@override
String get euukDeprecated => 'ЄС/Великобританія (Застаріле)';
@override
String get euukNarrow => 'ЄС/Великобританія (Вузький)';
@override
String get usacanadaRecommended => 'США/Канада (Рекомендовано)';
@override
String get vietnamDeprecated => 'В\'єтнам (Застаріле)';
@override
String get vietnamNarrow => 'В\'єтнам (Вузький)';
@override
String get active => 'Активно';
@override
String get addContact => 'Додати контакт';
@override
String get all => 'Усі';
@override
String get autoResolve => 'Автоматичне вирішення';
@override
String get clearAllLabel => 'Очистити все';
@override
String get clearRelays => 'Очистити реле';
@override
String get clearFilters => 'Очистити фільтри';
@override
String get clearRoute => 'Очистити маршрут';
@override
String get clearMessages => 'Очистити повідомлення';
@override
String get clearScale => 'Очистити масштаб';
@override
String get clearDiscoveries => 'Очистити виявлення';
@override
String get clearOnlineTraceDatabase => 'Очистити базу трасувань';
@override
String get clearAllChannels => 'Очистити всі канали';
@override
String get clearAllContacts => 'Очистити всі контакти';
@override
String get clearChannels => 'Очистити канали';
@override
String get clearContacts => 'Очистити контакти';
@override
String get clearPathOnMaxRetry => 'Очистити шлях при макс. спробі';
@override
String get create => 'Створити';
@override
String get custom => 'Користувацький';
@override
String get defaultValue => 'За замовчуванням';
@override
String get duplicate => 'Дублювати';
@override
String get editName => 'Редагувати ім\'я';
@override
String get open => 'Відкрити';
@override
String get paste => 'Вставити';
@override
String get preview => 'Попередній перегляд';
@override
String get remove => 'Видалити';
@override
String get rename => 'Перейменувати';
@override
String get resolveAll => 'Вирішити все';
@override
String get send => 'Надіслати';
@override
String get sendAnyway => 'Надіслати все одно';
@override
String get share => 'Поділитися';
@override
String get shareContact => 'Поділитися контактом';
@override
String get trace => 'Трасування';
@override
String get use => 'Використати';
@override
String get useSelectedFrequency => 'Використати обрану частоту';
@override
String get discovery => 'Виявлення';
@override
String get discoverRepeaters => 'Виявити повторювачі';
@override
String get discoverSensors => 'Виявити датчики';
@override
String get repeaterDiscoverySent => 'Виявлення повторювачів надіслано';
@override
String get sensorDiscoverySent => 'Виявлення датчиків надіслано';
@override
String get clearedPendingDiscoveries => 'Очікувані виявлення очищено.';
@override
String get autoDiscovery => 'Автоматичне виявлення';
@override
String get enableAutomaticAdding => 'Увімкнути автоматичне додавання';
@override
String get autoaddRepeaters => 'Автододавання повторювачів';
@override
String get autoaddRoomServers => 'Автододавання серверів кімнат';
@override
String get autoaddSensors => 'Автододавання датчиків';
@override
String get autoaddUsers => 'Автододавання користувачів';
@override
String get overwriteOldestWhenFull =>
'Перезаписати найстаріші при заповненні';
@override
String get storage => 'Сховище';
@override
String get dangerZone => 'Небезпечна зона';
@override
String get profiles => 'Профілі';
@override
String get favourites => 'Обране';
@override
String get sensors => 'Датчики';
@override
String get others => 'Інші';
@override
String get gpsModule => 'Модуль GPS';
@override
String get liveTraffic => 'Живий трафік';
@override
String get repeatersMap => 'Карта повторювачів';
@override
String get spectrumScan => 'Сканування спектру';
@override
String get blePacketLogs => 'Журнали BLE-пакетів';
@override
String get onlineTraceDatabase => 'База трасувань';
@override
String get routePathByteSize => 'Розмір шляху в байтах';
@override
String get messageNotifications => 'Сповіщення про повідомлення';
@override
String get sarAlerts => 'SAR-сповіщення';
@override
String get discoveryNotifications => 'Сповіщення про виявлення';
@override
String get updateNotifications => 'Сповіщення про оновлення';
@override
String get muteWhileAppIsOpen => 'Без звуку при відкритому додатку';
@override
String get disableContacts => 'Вимкнути контакти';
@override
String get enableSensorsTab => 'Увімкнути вкладку Датчики';
@override
String get enableProfiles => 'Увімкнути профілі';
@override
String get autoRouteRotation => 'Автоматична ротація маршруту';
@override
String get nearestRepeaterFallback => 'Найближчий повторювач як резерв';
@override
String get deleteAllStoredMessageHistory =>
'Видалити всю історію повідомлень';
@override
String get messageFontSize => 'Розмір шрифту повідомлень';
@override
String get rotateMapWithHeading => 'Обертати карту за напрямком';
@override
String get showMapDebugInfo => 'Показати налагоджувальну інфо карти';
@override
String get openMapInFullscreen => 'Відкрити карту на повний екран';
@override
String get showSarMarkersLabel => 'Показати SAR-маркери';
@override
String get displaySarMarkersOnTheMainMap =>
'Показувати SAR-маркери на головній карті';
@override
String get showAllContactTrailsLabel => 'Показати всі сліди контактів';
@override
String get hideRepeatersOnMap => 'Сховати повторювачі на карті';
@override
String get setMapScale => 'Встановити масштаб карти';
@override
String get customMapScaleSaved => 'Користувацький масштаб карти збережено';
@override
String get voiceBitrate => 'Бітрейт голосу';
@override
String get voiceCompressor => 'Компресор голосу';
@override
String get balancesQuietAndLoudSpeechLevels =>
'Вирівнює тихий і гучний рівні мови';
@override
String get voiceLimiter => 'Лімітер голосу';
@override
String get preventsClippingPeaksBeforeEncoding =>
'Запобігає обрізанню піків перед кодуванням';
@override
String get micAutoGain => 'Автопідсилення мікрофона';
@override
String get letsTheRecorderAdjustInputLevel =>
'Дозволяє рекордеру налаштувати рівень входу';
@override
String get echoCancellation => 'Придушення ехо';
@override
String get noiseSuppression => 'Придушення шуму';
@override
String get trimSilenceInVoiceMessages =>
'Обрізати тишу в голосових повідомленнях';
@override
String get compressor => 'Компресор';
@override
String get limiter => 'Лімітер';
@override
String get autoGain => 'Автопідсилення';
@override
String get echoCancel => 'Ехо';
@override
String get noiseSuppress => 'Шум';
@override
String get silenceTrim => 'Тиша';
@override
String get maxImageSize => 'Максимальний розмір зображення';
@override
String get imageCompression => 'Стиснення зображення';
@override
String get grayscale => 'Відтінки сірого';
@override
String get ultraMode => 'Ультра режим';
@override
String get fastPrivateGpsUpdates => 'Швидкі приватні GPS-оновлення';
@override
String get movementThreshold => 'Поріг руху';
@override
String get fastGpsMovementThreshold => 'Поріг руху швидкого GPS';
@override
String get fastGpsActiveuseInterval =>
'Інтервал активного використання швидкого GPS';
@override
String get activeuseUpdateInterval =>
'Інтервал оновлення при активному використанні';
@override
String get repeatNearbyTraffic => 'Повторювати близький трафік';
@override
String get relayThroughRepeatersAcrossTheMesh =>
'Ретранслювати через повторювачі по мережі';
@override
String get nearbyOnlyWithoutRepeaterFlooding =>
'Лише поблизу, без flood повторювачів';
@override
String get multihop => 'Багатохоповий';
@override
String get createProfile => 'Створити профіль';
@override
String get renameProfile => 'Перейменувати профіль';
@override
String get newProfile => 'Новий профіль';
@override
String get manageProfiles => 'Керувати профілями';
@override
String get enableProfilesToStartManagingThem =>
'Увімкніть профілі, щоб почати керувати ними.';
@override
String get openMessage => 'Відкрити повідомлення';
@override
String get jumpToTheRelatedSarMessage =>
'Перейти до пов\'язаного SAR-повідомлення';
@override
String get removeSarMarker => 'Видалити SAR-маркер';
@override
String get pleaseSelectADestinationToSendSarMarker =>
'Виберіть призначення для надсилання SAR-маркера';
@override
String get sarMarkerBroadcastToPublicChannel =>
'SAR-маркер надіслано на публічний канал';
@override
String get sarMarkerSentToRoom => 'SAR-маркер надіслано в кімнату';
@override
String get loadFromGallery => 'Завантажити з галереї';
@override
String get replaceImage => 'Замінити зображення';
@override
String get selectFromGallery => 'Вибрати з галереї';
@override
String get team => 'Команда';
@override
String get found => 'Знайдено';
@override
String get staging => 'Місце збору';
@override
String get object => 'Об\'єкт';
@override
String get quiet => 'Тихо';
@override
String get moderate => 'Помірно';
@override
String get busy => 'Зайнято';
@override
String get spectrumScanReturnedNoCandidateFrequencies =>
'Сканування спектру не знайшло частот-кандидатів';
@override
String get searchMessages => 'Пошук повідомлень';
@override
String get sendImageFromGallery => 'Надіслати зображення з галереї';
@override
String get takePhoto => 'Зробити фото';
@override
String get dmOnly => 'Лише особисті повідомлення';
@override
String get allMessages => 'Усі повідомлення';
@override
String get sendToPublicChannel => 'Надіслати в публічний канал?';
@override
String get selectMarkerTypeAndDestination =>
'Виберіть тип маркера та призначення';
@override
String get noDestinationsAvailableLabel => 'Немає доступних призначень';
@override
String get image => 'Зображення';
@override
String get format => 'Формат';
@override
String get dimensions => 'Розміри';
@override
String get segments => 'Сегменти';
@override
String get transfers => 'Передачі';
@override
String get downloadedBy => 'Завантажено';
@override
String get saveDiscoverySettings => 'Зберегти налаштування виявлення';
@override
String get savePublicInfo => 'Зберегти публічну інформацію';
@override
String get saveRadioSettings => 'Зберегти налаштування радіо';
@override
String get savePath => 'Зберегти шлях';
@override
String get wipeDeviceData => 'Стерти дані пристрою';
@override
String get wipeDevice => 'Стерти пристрій';
@override
String get destructiveDeviceActions => 'Деструктивні дії пристрою.';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings =>
'Оберіть пресет або налаштуйте радіо вручну.';
@override
String get chooseTheNameAndLocationThisDeviceShares =>
'Оберіть ім\'я та місцеположення, якими ділиться пристрій.';
@override
String get availableSpaceOnThisDevice =>
'Доступний простір на цьому пристрої.';
@override
String get used => 'Використано';
@override
String get total => 'Загалом';
@override
String get renameValue => 'Перейменувати значення';
@override
String get customizeFields => 'Налаштувати поля';
@override
String get livePreview => 'Попередній перегляд наживо';
@override
String get refreshSchedule => 'Розклад оновлення';
@override
String get noResponse => 'Немає відповіді';
@override
String get refreshing => 'Оновлення';
@override
String get unavailable => 'Недоступно';
@override
String get pickARelayOrNodeToWatchInSensors =>
'Оберіть реле або вузол для спостереження.';
@override
String get publicKeyLabel => 'Публічний ключ';
@override
String get alreadyInContacts => 'Вже в контактах';
@override
String get connectToADeviceBeforeAddingContacts =>
'Підключіться до пристрою перед додаванням контактів';
@override
String get fromContacts => 'З контактів';
@override
String get onlineOnly => 'Лише онлайн';
@override
String get inBoth => 'В обох';
@override
String get source => 'Джерело';
@override
String get manualRouteEdit => 'Ручне редагування маршруту';
@override
String get observedMeshRoute => 'Спостережуваний mesh-маршрут';
@override
String get allMessagesCleared => 'Усі повідомлення очищено';
@override
String get onlineTraceDatabaseCleared => 'Базу трасувань очищено';
@override
String get packetLogsCleared => 'Журнали пакетів очищено';
@override
String get hexDataCopiedToClipboard => 'Hex-дані скопійовано в буфер';
@override
String get developerModeEnabled => 'Режим розробника увімкнено';
@override
String get developerModeDisabled => 'Режим розробника вимкнено';
@override
String get clipboardIsEmpty => 'Буфер обміну порожній';
@override
String get contactImported => 'Контакт імпортовано';
@override
String get contactLinkCopiedToClipboard =>
'Посилання на контакт скопійовано в буфер';
@override
String get failedToExportContact => 'Не вдалося експортувати контакт';
@override
String get noLogsToExport => 'Немає журналів для експорту';
@override
String get exportAsCsv => 'Експортувати як CSV';
@override
String get exportAsText => 'Експортувати як текст';
@override
String get receivedRfc3339 => 'Отримано (RFC3339)';
@override
String get buildTime => 'Час збірки';
@override
String get downloadUrlNotAvailable => 'URL завантаження недоступний';
@override
String get cannotOpenDownloadUrl => 'Не вдається відкрити URL завантаження';
@override
String get updateCheckIsOnlyAvailableOnAndroid =>
'Перевірка оновлень доступна лише на Android';
@override
String get youAreRunningTheLatestVersion =>
'Ви використовуєте останню версію';
@override
String get updateAvailableButDownloadUrlNotFound =>
'Оновлення доступне, але URL завантаження не знайдено';
@override
String get startTictactoe => 'Почати Tic-Tac-Toe';
@override
String get tictactoeUnavailable => 'Tic-Tac-Toe недоступно';
@override
String get tictactoeOpponentUnknown => 'Tic-Tac-Toe: суперник невідомий';
@override
String get tictactoeWaitingForStart => 'Tic-Tac-Toe: очікування початку';
@override
String get acceptsShareLinks => 'Приймає посилання для обміну';
@override
String get supportsRawHex => 'Підтримує raw hex';
@override
String get clipboardfriendly => 'Зручно для буфера';
@override
String get captured => 'Захоплено';
@override
String get size => 'Розмір';
@override
String get noCustomChannelsToClear =>
'Немає користувацьких каналів для очищення.';
@override
String get noDeviceContactsToClear =>
'Немає контактів пристрою для очищення.';
}

View File

@@ -1827,4 +1827,745 @@ class AppLocalizationsZh extends AppLocalizations {
String contactFailedToSetRoute(String error) {
return '设置路由失败:$error';
}
@override
String get rssi => 'RSSI';
@override
String get snr => 'SNR';
@override
String get ackTimeout => 'ACK 超时';
@override
String get opcode => '操作码';
@override
String get payload => '有效载荷';
@override
String get hops => '跳数';
@override
String get hashSize => '哈希大小';
@override
String get pathBytes => '路径字节';
@override
String get selectedPath => '选定路径';
@override
String get estimatedTx => '预计传输';
@override
String get senderToReceipt => '发送到接收';
@override
String get receivedCopies => '收到的副本';
@override
String get retryCause => '重试原因';
@override
String get retryMode => '重试模式';
@override
String get retryResult => '重试结果';
@override
String get lastRetry => '最后重试';
@override
String get rxPackets => 'RX 数据包';
@override
String get mesh => 'Mesh';
@override
String get rate => '速率';
@override
String get window => '窗口';
@override
String get posttxDelay => '发送后延迟';
@override
String get bandpass => '带通';
@override
String get bandpassFilterVoice => '带通语音滤波';
@override
String get frequency => '频率';
@override
String get australia => '澳大利亚';
@override
String get australiaNarrow => '澳大利亚(窄带)';
@override
String get australiaQld => '澳大利亚QLD';
@override
String get australiaSaWa => '澳大利亚SA, WA';
@override
String get newZealand => '新西兰';
@override
String get newZealandNarrow => '新西兰(窄带)';
@override
String get switzerland => '瑞士';
@override
String get portugal433 => '葡萄牙 433';
@override
String get portugal868 => '葡萄牙 868';
@override
String get czechRepublicNarrow => '捷克(窄带)';
@override
String get eu433mhzLongRange => '欧盟 433MHz远距离';
@override
String get euukDeprecated => '欧盟/英国(已弃用)';
@override
String get euukNarrow => '欧盟/英国(窄带)';
@override
String get usacanadaRecommended => '美国/加拿大(推荐)';
@override
String get vietnamDeprecated => '越南(已弃用)';
@override
String get vietnamNarrow => '越南(窄带)';
@override
String get active => '活动';
@override
String get addContact => '添加联系人';
@override
String get all => '全部';
@override
String get autoResolve => '自动解析';
@override
String get clearAllLabel => '清除全部';
@override
String get clearRelays => '清除中继';
@override
String get clearFilters => '清除过滤器';
@override
String get clearRoute => '清除路由';
@override
String get clearMessages => '清除消息';
@override
String get clearScale => '清除比例';
@override
String get clearDiscoveries => '清除发现';
@override
String get clearOnlineTraceDatabase => '清除在线追踪数据库';
@override
String get clearAllChannels => '清除所有频道';
@override
String get clearAllContacts => '清除所有联系人';
@override
String get clearChannels => '清除频道';
@override
String get clearContacts => '清除联系人';
@override
String get clearPathOnMaxRetry => '最大重试时清除路径';
@override
String get create => '创建';
@override
String get custom => '自定义';
@override
String get defaultValue => '默认';
@override
String get duplicate => '复制';
@override
String get editName => '编辑名称';
@override
String get open => '打开';
@override
String get paste => '粘贴';
@override
String get preview => '预览';
@override
String get remove => '移除';
@override
String get rename => '重命名';
@override
String get resolveAll => '解析全部';
@override
String get send => '发送';
@override
String get sendAnyway => '仍然发送';
@override
String get share => '分享';
@override
String get shareContact => '分享联系人';
@override
String get trace => '追踪';
@override
String get use => '使用';
@override
String get useSelectedFrequency => '使用选定频率';
@override
String get discovery => '发现';
@override
String get discoverRepeaters => '发现中继器';
@override
String get discoverSensors => '发现传感器';
@override
String get repeaterDiscoverySent => '中继器发现已发送';
@override
String get sensorDiscoverySent => '传感器发现已发送';
@override
String get clearedPendingDiscoveries => '已清除待处理的发现。';
@override
String get autoDiscovery => '自动发现';
@override
String get enableAutomaticAdding => '启用自动添加';
@override
String get autoaddRepeaters => '自动添加中继器';
@override
String get autoaddRoomServers => '自动添加房间服务器';
@override
String get autoaddSensors => '自动添加传感器';
@override
String get autoaddUsers => '自动添加用户';
@override
String get overwriteOldestWhenFull => '满时覆盖最旧的';
@override
String get storage => '存储';
@override
String get dangerZone => '危险区域';
@override
String get profiles => '配置文件';
@override
String get favourites => '收藏夹';
@override
String get sensors => '传感器';
@override
String get others => '其他';
@override
String get gpsModule => 'GPS 模块';
@override
String get liveTraffic => '实时流量';
@override
String get repeatersMap => '中继器地图';
@override
String get spectrumScan => '频谱扫描';
@override
String get blePacketLogs => 'BLE 数据包日志';
@override
String get onlineTraceDatabase => '在线追踪数据库';
@override
String get routePathByteSize => '路由路径字节大小';
@override
String get messageNotifications => '消息通知';
@override
String get sarAlerts => 'SAR 警报';
@override
String get discoveryNotifications => '发现通知';
@override
String get updateNotifications => '更新通知';
@override
String get muteWhileAppIsOpen => '应用打开时静音';
@override
String get disableContacts => '禁用联系人';
@override
String get enableSensorsTab => '启用传感器标签';
@override
String get enableProfiles => '启用配置文件';
@override
String get autoRouteRotation => '自动路由轮换';
@override
String get nearestRepeaterFallback => '最近中继器回退';
@override
String get deleteAllStoredMessageHistory => '删除所有存储的消息历史';
@override
String get messageFontSize => '消息字体大小';
@override
String get rotateMapWithHeading => '随航向旋转地图';
@override
String get showMapDebugInfo => '显示地图调试信息';
@override
String get openMapInFullscreen => '全屏打开地图';
@override
String get showSarMarkersLabel => '显示 SAR 标记';
@override
String get displaySarMarkersOnTheMainMap => '在主地图上显示 SAR 标记';
@override
String get showAllContactTrailsLabel => '显示所有联系人轨迹';
@override
String get hideRepeatersOnMap => '在地图上隐藏中继器';
@override
String get setMapScale => '设置地图比例';
@override
String get customMapScaleSaved => '自定义地图比例已保存';
@override
String get voiceBitrate => '语音比特率';
@override
String get voiceCompressor => '语音压缩器';
@override
String get balancesQuietAndLoudSpeechLevels => '平衡安静和响亮的语音级别';
@override
String get voiceLimiter => '语音限制器';
@override
String get preventsClippingPeaksBeforeEncoding => '防止编码前的削波';
@override
String get micAutoGain => '麦克风自动增益';
@override
String get letsTheRecorderAdjustInputLevel => '让录音器调整输入级别';
@override
String get echoCancellation => '回声消除';
@override
String get noiseSuppression => '噪音抑制';
@override
String get trimSilenceInVoiceMessages => '修剪语音消息中的静音';
@override
String get compressor => '压缩器';
@override
String get limiter => '限制器';
@override
String get autoGain => '自动增益';
@override
String get echoCancel => '回声';
@override
String get noiseSuppress => '噪声';
@override
String get silenceTrim => '静音';
@override
String get maxImageSize => '最大图片大小';
@override
String get imageCompression => '图片压缩';
@override
String get grayscale => '灰度';
@override
String get ultraMode => '超级模式';
@override
String get fastPrivateGpsUpdates => '快速私有 GPS 更新';
@override
String get movementThreshold => '移动阈值';
@override
String get fastGpsMovementThreshold => '快速 GPS 移动阈值';
@override
String get fastGpsActiveuseInterval => '快速 GPS 活动使用间隔';
@override
String get activeuseUpdateInterval => '活动使用更新间隔';
@override
String get repeatNearbyTraffic => '转发附近流量';
@override
String get relayThroughRepeatersAcrossTheMesh => '通过网格中的中继器转发';
@override
String get nearbyOnlyWithoutRepeaterFlooding => '仅附近,无中继器泛洪';
@override
String get multihop => '多跳';
@override
String get createProfile => '创建配置文件';
@override
String get renameProfile => '重命名配置文件';
@override
String get newProfile => '新配置文件';
@override
String get manageProfiles => '管理配置文件';
@override
String get enableProfilesToStartManagingThem => '启用配置文件以开始管理它们。';
@override
String get openMessage => '打开消息';
@override
String get jumpToTheRelatedSarMessage => '跳转到相关的 SAR 消息';
@override
String get removeSarMarker => '移除 SAR 标记';
@override
String get pleaseSelectADestinationToSendSarMarker => '请选择目标以发送 SAR 标记';
@override
String get sarMarkerBroadcastToPublicChannel => 'SAR 标记已广播到公共频道';
@override
String get sarMarkerSentToRoom => 'SAR 标记已发送到房间';
@override
String get loadFromGallery => '从图库加载';
@override
String get replaceImage => '替换图片';
@override
String get selectFromGallery => '从图库选择';
@override
String get team => '团队';
@override
String get found => '已找到';
@override
String get staging => '集结区';
@override
String get object => '物体';
@override
String get quiet => '安静';
@override
String get moderate => '中等';
@override
String get busy => '繁忙';
@override
String get spectrumScanReturnedNoCandidateFrequencies => '频谱扫描未找到候选频率';
@override
String get searchMessages => '搜索消息';
@override
String get sendImageFromGallery => '从图库发送图片';
@override
String get takePhoto => '拍照';
@override
String get dmOnly => '仅私信';
@override
String get allMessages => '所有消息';
@override
String get sendToPublicChannel => '发送到公共频道?';
@override
String get selectMarkerTypeAndDestination => '选择标记类型和目标';
@override
String get noDestinationsAvailableLabel => '没有可用的目标';
@override
String get image => '图片';
@override
String get format => '格式';
@override
String get dimensions => '尺寸';
@override
String get segments => '分段';
@override
String get transfers => '传输';
@override
String get downloadedBy => '已下载';
@override
String get saveDiscoverySettings => '保存发现设置';
@override
String get savePublicInfo => '保存公共信息';
@override
String get saveRadioSettings => '保存无线电设置';
@override
String get savePath => '保存路径';
@override
String get wipeDeviceData => '擦除设备数据';
@override
String get wipeDevice => '擦除设备';
@override
String get destructiveDeviceActions => '破坏性设备操作。';
@override
String get chooseAPresetOrFinetuneCustomRadioSettings => '选择预设或微调自定义无线电设置。';
@override
String get chooseTheNameAndLocationThisDeviceShares => '选择此设备共享的名称和位置。';
@override
String get availableSpaceOnThisDevice => '此设备上的可用空间。';
@override
String get used => '已使用';
@override
String get total => '总计';
@override
String get renameValue => '重命名值';
@override
String get customizeFields => '自定义字段';
@override
String get livePreview => '实时预览';
@override
String get refreshSchedule => '刷新计划';
@override
String get noResponse => '无响应';
@override
String get refreshing => '刷新中';
@override
String get unavailable => '不可用';
@override
String get pickARelayOrNodeToWatchInSensors => '选择要在传感器中监视的中继或节点。';
@override
String get publicKeyLabel => '公钥';
@override
String get alreadyInContacts => '已在联系人中';
@override
String get connectToADeviceBeforeAddingContacts => '添加联系人前请连接到设备';
@override
String get fromContacts => '来自联系人';
@override
String get onlineOnly => '仅在线';
@override
String get inBoth => '两者中';
@override
String get source => '来源';
@override
String get manualRouteEdit => '手动路由编辑';
@override
String get observedMeshRoute => '观察到的网格路由';
@override
String get allMessagesCleared => '所有消息已清除';
@override
String get onlineTraceDatabaseCleared => '在线追踪数据库已清除';
@override
String get packetLogsCleared => '数据包日志已清除';
@override
String get hexDataCopiedToClipboard => '十六进制数据已复制到剪贴板';
@override
String get developerModeEnabled => '开发者模式已启用';
@override
String get developerModeDisabled => '开发者模式已禁用';
@override
String get clipboardIsEmpty => '剪贴板为空';
@override
String get contactImported => '联系人已导入';
@override
String get contactLinkCopiedToClipboard => '联系人链接已复制到剪贴板';
@override
String get failedToExportContact => '导出联系人失败';
@override
String get noLogsToExport => '没有日志可导出';
@override
String get exportAsCsv => '导出为 CSV';
@override
String get exportAsText => '导出为文本';
@override
String get receivedRfc3339 => '收到 (RFC3339)';
@override
String get buildTime => '构建时间';
@override
String get downloadUrlNotAvailable => '下载链接不可用';
@override
String get cannotOpenDownloadUrl => '无法打开下载链接';
@override
String get updateCheckIsOnlyAvailableOnAndroid => '更新检查仅在 Android 上可用';
@override
String get youAreRunningTheLatestVersion => '您正在使用最新版本';
@override
String get updateAvailableButDownloadUrlNotFound => '有更新可用但未找到下载链接';
@override
String get startTictactoe => '开始井字棋';
@override
String get tictactoeUnavailable => '井字棋不可用';
@override
String get tictactoeOpponentUnknown => '井字棋:对手未知';
@override
String get tictactoeWaitingForStart => '井字棋:等待开始';
@override
String get acceptsShareLinks => '接受分享链接';
@override
String get supportsRawHex => '支持原始十六进制';
@override
String get clipboardfriendly => '剪贴板友好';
@override
String get captured => '已捕获';
@override
String get size => '大小';
@override
String get noCustomChannelsToClear => '没有自定义频道可清除。';
@override
String get noDeviceContactsToClear => '没有设备联系人可清除。';
}

View File

@@ -2328,5 +2328,252 @@
"contactFailedToClearRoute": "Nie udało się wyczyścić trasy: {error}",
"contactRouteCleared": "Trasa wyczyszczona",
"contactRouteSet": "Ustawiono trasę: {route}",
"contactFailedToSetRoute": "Nie udało się ustawić trasy: {error}"
"contactFailedToSetRoute": "Nie udało się ustawić trasy: {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "Limit czasu ACK",
"opcode": "Opcode",
"payload": "Ładunek",
"hops": "Skoki",
"hashSize": "Rozmiar hash",
"pathBytes": "Bajty ścieżki",
"selectedPath": "Wybrana ścieżka",
"estimatedTx": "Szacowane nadawanie",
"senderToReceipt": "Od nadawcy do odbioru",
"receivedCopies": "Otrzymane kopie",
"retryCause": "Przyczyna ponowienia",
"retryMode": "Tryb ponowienia",
"retryResult": "Wynik ponowienia",
"lastRetry": "Ostatnia próba",
"rxPackets": "Pakiety RX",
"mesh": "Mesh",
"rate": "Szybkość",
"window": "Okno",
"posttxDelay": "Opóźnienie po nadawaniu",
"bandpass": "Pasmowy",
"bandpassFilterVoice": "Filtr pasmowy głosu",
"frequency": "Częstotliwość",
"australia": "Australia",
"australiaNarrow": "Australia (wąski)",
"australiaQld": "Australia: QLD",
"australiaSaWa": "Australia: SA, WA",
"newZealand": "Nowa Zelandia",
"newZealandNarrow": "Nowa Zelandia (wąski)",
"switzerland": "Szwajcaria",
"portugal433": "Portugalia 433",
"portugal868": "Portugalia 868",
"czechRepublicNarrow": "Czechy (wąski)",
"eu433mhzLongRange": "EU 433MHz (daleki zasięg)",
"euukDeprecated": "EU/UK (przestarzały)",
"euukNarrow": "EU/UK (wąski)",
"usacanadaRecommended": "USA/Kanada (zalecany)",
"vietnamDeprecated": "Wietnam (przestarzały)",
"vietnamNarrow": "Wietnam (wąski)",
"active": "Aktywny",
"addContact": "Dodaj kontakt",
"all": "Wszystko",
"autoResolve": "Automatyczne rozwiązanie",
"clearAllLabel": "Wyczyść wszystko",
"clearRelays": "Wyczyść przekaźniki",
"clearFilters": "Wyczyść filtry",
"clearRoute": "Wyczyść trasę",
"clearMessages": "Wyczyść wiadomości",
"clearScale": "Wyczyść skalę",
"clearDiscoveries": "Wyczyść odkrycia",
"clearOnlineTraceDatabase": "Wyczyść bazę śladów",
"clearAllChannels": "Wyczyść wszystkie kanały",
"clearAllContacts": "Wyczyść wszystkie kontakty",
"clearChannels": "Wyczyść kanały",
"clearContacts": "Wyczyść kontakty",
"clearPathOnMaxRetry": "Wyczyść ścieżkę przy maks. próbie",
"create": "Utwórz",
"custom": "Niestandardowy",
"defaultValue": "Domyślny",
"duplicate": "Duplikuj",
"editName": "Edytuj nazwę",
"open": "Otwórz",
"paste": "Wklej",
"preview": "Podgląd",
"remove": "Usuń",
"rename": "Zmień nazwę",
"resolveAll": "Rozwiąż wszystko",
"send": "Wyślij",
"sendAnyway": "Wyślij mimo to",
"share": "Udostępnij",
"shareContact": "Udostępnij kontakt",
"trace": "Śledzenie",
"use": "Użyj",
"useSelectedFrequency": "Użyj wybranej częstotliwości",
"discovery": "Wykrywanie",
"discoverRepeaters": "Wykryj przekaźniki",
"discoverSensors": "Wykryj czujniki",
"repeaterDiscoverySent": "Wykrywanie przekaźników wysłane",
"sensorDiscoverySent": "Wykrywanie czujników wysłane",
"clearedPendingDiscoveries": "Wyczyszczono oczekujące odkrycia.",
"autoDiscovery": "Automatyczne wykrywanie",
"enableAutomaticAdding": "Włącz automatyczne dodawanie",
"autoaddRepeaters": "Automatycznie dodaj przekaźniki",
"autoaddRoomServers": "Automatycznie dodaj serwery pokoi",
"autoaddSensors": "Automatycznie dodaj czujniki",
"autoaddUsers": "Automatycznie dodaj użytkowników",
"overwriteOldestWhenFull": "Nadpisz najstarsze gdy pełne",
"storage": "Pamięć",
"dangerZone": "Strefa zagrożenia",
"profiles": "Profile",
"favourites": "Ulubione",
"sensors": "Czujniki",
"others": "Inne",
"gpsModule": "Moduł GPS",
"liveTraffic": "Ruch na żywo",
"repeatersMap": "Mapa przekaźników",
"spectrumScan": "Skanowanie spektrum",
"blePacketLogs": "Logi pakietów BLE",
"onlineTraceDatabase": "Baza śladów",
"routePathByteSize": "Rozmiar ścieżki w bajtach",
"messageNotifications": "Powiadomienia o wiadomościach",
"sarAlerts": "Alerty SAR",
"discoveryNotifications": "Powiadomienia o wykrywaniu",
"updateNotifications": "Powiadomienia o aktualizacjach",
"muteWhileAppIsOpen": "Wycisz gdy aplikacja jest otwarta",
"disableContacts": "Wyłącz kontakty",
"enableSensorsTab": "Włącz kartę Czujniki",
"enableProfiles": "Włącz profile",
"autoRouteRotation": "Automatyczna rotacja trasy",
"nearestRepeaterFallback": "Najbliższy przekaźnik awaryjny",
"deleteAllStoredMessageHistory": "Usuń całą historię wiadomości",
"messageFontSize": "Rozmiar czcionki wiadomości",
"rotateMapWithHeading": "Obracaj mapę z kierunkiem",
"showMapDebugInfo": "Pokaż info debugowania mapy",
"openMapInFullscreen": "Otwórz mapę na pełnym ekranie",
"showSarMarkersLabel": "Pokaż znaczniki SAR",
"displaySarMarkersOnTheMainMap": "Wyświetlaj znaczniki SAR na głównej mapie",
"showAllContactTrailsLabel": "Pokaż wszystkie ślady kontaktów",
"hideRepeatersOnMap": "Ukryj przekaźniki na mapie",
"setMapScale": "Ustaw skalę mapy",
"customMapScaleSaved": "Niestandardowa skala mapy zapisana",
"voiceBitrate": "Bitrate głosu",
"voiceCompressor": "Kompresor głosu",
"balancesQuietAndLoudSpeechLevels": "Wyrównuje ciche i głośne poziomy mowy",
"voiceLimiter": "Limiter głosu",
"preventsClippingPeaksBeforeEncoding": "Zapobiega przycinaniu szczytów przed kodowaniem",
"micAutoGain": "Automatyczne wzmocnienie mikrofonu",
"letsTheRecorderAdjustInputLevel": "Pozwala nagrywarkowi dostosować poziom wejścia",
"echoCancellation": "Usuwanie echa",
"noiseSuppression": "Tłumienie szumu",
"trimSilenceInVoiceMessages": "Przytnij ciszę w wiadomościach głosowych",
"compressor": "Kompresor",
"limiter": "Limiter",
"autoGain": "Automatyczne wzmocnienie",
"echoCancel": "Echo",
"noiseSuppress": "Szum",
"silenceTrim": "Cisza",
"maxImageSize": "Maksymalny rozmiar obrazu",
"imageCompression": "Kompresja obrazu",
"grayscale": "Skala szarości",
"ultraMode": "Tryb ultra",
"fastPrivateGpsUpdates": "Szybkie prywatne aktualizacje GPS",
"movementThreshold": "Próg ruchu",
"fastGpsMovementThreshold": "Próg ruchu szybkiego GPS",
"fastGpsActiveuseInterval": "Interwał aktywnego użycia szybkiego GPS",
"activeuseUpdateInterval": "Interwał aktualizacji przy aktywnym użyciu",
"repeatNearbyTraffic": "Powtarzaj pobliski ruch",
"relayThroughRepeatersAcrossTheMesh": "Przekazuj przez przekaźniki w sieci",
"nearbyOnlyWithoutRepeaterFlooding": "Tylko w pobliżu, bez zalewania przekaźników",
"multihop": "Wieloskokowy",
"createProfile": "Utwórz profil",
"renameProfile": "Zmień nazwę profilu",
"newProfile": "Nowy profil",
"manageProfiles": "Zarządzaj profilami",
"enableProfilesToStartManagingThem": "Włącz profile, aby zacząć nimi zarządzać.",
"openMessage": "Otwórz wiadomość",
"jumpToTheRelatedSarMessage": "Przejdź do powiązanej wiadomości SAR",
"removeSarMarker": "Usuń znacznik SAR",
"pleaseSelectADestinationToSendSarMarker": "Wybierz cel wysłania znacznika SAR",
"sarMarkerBroadcastToPublicChannel": "Znacznik SAR wysłany na kanał publiczny",
"sarMarkerSentToRoom": "Znacznik SAR wysłany do pokoju",
"loadFromGallery": "Załaduj z galerii",
"replaceImage": "Zamień obraz",
"selectFromGallery": "Wybierz z galerii",
"team": "Zespół",
"found": "Znaleziono",
"staging": "Punkt zbiórki",
"object": "Obiekt",
"quiet": "Cicho",
"moderate": "Umiarkowane",
"busy": "Zajęty",
"spectrumScanReturnedNoCandidateFrequencies": "Skanowanie spektrum nie znalazło częstotliwości kandydatów",
"searchMessages": "Szukaj wiadomości",
"sendImageFromGallery": "Wyślij obraz z galerii",
"takePhoto": "Zrób zdjęcie",
"dmOnly": "Tylko wiadomość bezpośrednia",
"allMessages": "Wszystkie wiadomości",
"sendToPublicChannel": "Wysłać na kanał publiczny?",
"selectMarkerTypeAndDestination": "Wybierz typ znacznika i cel",
"noDestinationsAvailableLabel": "Brak dostępnych celów",
"image": "Obraz",
"format": "Format",
"dimensions": "Wymiary",
"segments": "Segmenty",
"transfers": "Transfery",
"downloadedBy": "Pobrane przez",
"saveDiscoverySettings": "Zapisz ustawienia wykrywania",
"savePublicInfo": "Zapisz informacje publiczne",
"saveRadioSettings": "Zapisz ustawienia radia",
"savePath": "Zapisz ścieżkę",
"wipeDeviceData": "Wyczyść dane urządzenia",
"wipeDevice": "Wyczyść urządzenie",
"destructiveDeviceActions": "Destrukcyjne akcje na urządzeniu.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Wybierz ustawienie wstępne lub dostosuj ustawienia radia.",
"chooseTheNameAndLocationThisDeviceShares": "Wybierz nazwę i lokalizację udostępnianą przez to urządzenie.",
"availableSpaceOnThisDevice": "Dostępne miejsce na tym urządzeniu.",
"used": "Użyto",
"total": "Razem",
"renameValue": "Zmień nazwę wartości",
"customizeFields": "Dostosuj pola",
"livePreview": "Podgląd na żywo",
"refreshSchedule": "Harmonogram odświeżania",
"noResponse": "Brak odpowiedzi",
"refreshing": "Odświeżanie",
"unavailable": "Niedostępny",
"pickARelayOrNodeToWatchInSensors": "Wybierz przekaźnik lub węzeł do obserwacji.",
"publicKeyLabel": "Klucz publiczny",
"alreadyInContacts": "Już w kontaktach",
"connectToADeviceBeforeAddingContacts": "Połącz się z urządzeniem przed dodaniem kontaktów",
"fromContacts": "Z kontaktów",
"onlineOnly": "Tylko online",
"inBoth": "W obu",
"source": "Źródło",
"manualRouteEdit": "Ręczna edycja trasy",
"observedMeshRoute": "Obserwowana trasa sieci",
"allMessagesCleared": "Wszystkie wiadomości wyczyszczone",
"onlineTraceDatabaseCleared": "Baza śladów wyczyszczona",
"packetLogsCleared": "Logi pakietów wyczyszczone",
"hexDataCopiedToClipboard": "Dane hex skopiowane do schowka",
"developerModeEnabled": "Tryb programisty włączony",
"developerModeDisabled": "Tryb programisty wyłączony",
"clipboardIsEmpty": "Schowek jest pusty",
"contactImported": "Kontakt zaimportowany",
"contactLinkCopiedToClipboard": "Link kontaktu skopiowany do schowka",
"failedToExportContact": "Nie udało się wyeksportować kontaktu",
"noLogsToExport": "Brak logów do eksportu",
"exportAsCsv": "Eksportuj jako CSV",
"exportAsText": "Eksportuj jako tekst",
"receivedRfc3339": "Odebrano (RFC3339)",
"buildTime": "Czas budowania",
"downloadUrlNotAvailable": "URL pobierania niedostępny",
"cannotOpenDownloadUrl": "Nie można otworzyć URL pobierania",
"updateCheckIsOnlyAvailableOnAndroid": "Sprawdzanie aktualizacji dostępne tylko na Androidzie",
"youAreRunningTheLatestVersion": "Używasz najnowszej wersji",
"updateAvailableButDownloadUrlNotFound": "Aktualizacja dostępna, ale URL pobierania nie znaleziony",
"startTictactoe": "Rozpocznij Tic-Tac-Toe",
"tictactoeUnavailable": "Tic-Tac-Toe niedostępny",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: przeciwnik nieznany",
"tictactoeWaitingForStart": "Tic-Tac-Toe: oczekiwanie na start",
"acceptsShareLinks": "Akceptuje udostępnione linki",
"supportsRawHex": "Obsługuje surowy hex",
"clipboardfriendly": "Przyjazne dla schowka",
"captured": "Przechwycone",
"size": "Rozmiar",
"noCustomChannelsToClear": "Brak niestandardowych kanałów do wyczyszczenia.",
"noDeviceContactsToClear": "Brak kontaktów urządzenia do wyczyszczenia."
}

View File

@@ -2328,5 +2328,252 @@
"contactFailedToClearRoute": "Falha ao limpar a rota: {error}",
"contactRouteCleared": "Rota limpa",
"contactRouteSet": "Rota definida: {route}",
"contactFailedToSetRoute": "Falha ao definir a rota: {error}"
"contactFailedToSetRoute": "Falha ao definir a rota: {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "Tempo limite ACK",
"opcode": "Opcode",
"payload": "Carga útil",
"hops": "Saltos",
"hashSize": "Tamanho do hash",
"pathBytes": "Bytes do caminho",
"selectedPath": "Caminho selecionado",
"estimatedTx": "Transmissão estimada",
"senderToReceipt": "Do remetente ao recibo",
"receivedCopies": "Cópias recebidas",
"retryCause": "Causa da nova tentativa",
"retryMode": "Modo de nova tentativa",
"retryResult": "Resultado da nova tentativa",
"lastRetry": "Última tentativa",
"rxPackets": "Pacotes RX",
"mesh": "Mesh",
"rate": "Taxa",
"window": "Janela",
"posttxDelay": "Atraso pós-tx",
"bandpass": "Passa-banda",
"bandpassFilterVoice": "Filtro passa-banda de voz",
"frequency": "Frequência",
"australia": "Austrália",
"australiaNarrow": "Austrália (Estreito)",
"australiaQld": "Austrália: QLD",
"australiaSaWa": "Austrália: SA, WA",
"newZealand": "Nova Zelândia",
"newZealandNarrow": "Nova Zelândia (Estreito)",
"switzerland": "Suíça",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "República Checa (Estreito)",
"eu433mhzLongRange": "UE 433MHz (Longo Alcance)",
"euukDeprecated": "UE/RU (Obsoleto)",
"euukNarrow": "UE/RU (Estreito)",
"usacanadaRecommended": "EUA/Canadá (Recomendado)",
"vietnamDeprecated": "Vietname (Obsoleto)",
"vietnamNarrow": "Vietname (Estreito)",
"active": "Ativo",
"addContact": "Adicionar contacto",
"all": "Tudo",
"autoResolve": "Resolver automaticamente",
"clearAllLabel": "Limpar tudo",
"clearRelays": "Limpar relays",
"clearFilters": "Limpar filtros",
"clearRoute": "Limpar rota",
"clearMessages": "Limpar mensagens",
"clearScale": "Limpar escala",
"clearDiscoveries": "Limpar descobertas",
"clearOnlineTraceDatabase": "Limpar base de dados de rastreio",
"clearAllChannels": "Limpar todos os canais",
"clearAllContacts": "Limpar todos os contactos",
"clearChannels": "Limpar canais",
"clearContacts": "Limpar contactos",
"clearPathOnMaxRetry": "Limpar caminho no máx. de tentativas",
"create": "Criar",
"custom": "Personalizado",
"defaultValue": "Padrão",
"duplicate": "Duplicar",
"editName": "Editar nome",
"open": "Abrir",
"paste": "Colar",
"preview": "Pré-visualização",
"remove": "Remover",
"rename": "Renomear",
"resolveAll": "Resolver tudo",
"send": "Enviar",
"sendAnyway": "Enviar mesmo assim",
"share": "Partilhar",
"shareContact": "Partilhar contacto",
"trace": "Rastreio",
"use": "Usar",
"useSelectedFrequency": "Usar frequência selecionada",
"discovery": "Descoberta",
"discoverRepeaters": "Descobrir repetidores",
"discoverSensors": "Descobrir sensores",
"repeaterDiscoverySent": "Descoberta de repetidores enviada",
"sensorDiscoverySent": "Descoberta de sensores enviada",
"clearedPendingDiscoveries": "Descobertas pendentes limpas.",
"autoDiscovery": "Descoberta automática",
"enableAutomaticAdding": "Ativar adição automática",
"autoaddRepeaters": "Adicionar repetidores automaticamente",
"autoaddRoomServers": "Adicionar servidores de sala automaticamente",
"autoaddSensors": "Adicionar sensores automaticamente",
"autoaddUsers": "Adicionar utilizadores automaticamente",
"overwriteOldestWhenFull": "Sobrescrever mais antigos quando cheio",
"storage": "Armazenamento",
"dangerZone": "Zona de perigo",
"profiles": "Perfis",
"favourites": "Favoritos",
"sensors": "Sensores",
"others": "Outros",
"gpsModule": "Módulo GPS",
"liveTraffic": "Tráfego ao vivo",
"repeatersMap": "Mapa de repetidores",
"spectrumScan": "Varredura de espectro",
"blePacketLogs": "Registos de pacotes BLE",
"onlineTraceDatabase": "Base de dados de rastreio",
"routePathByteSize": "Tamanho do caminho em bytes",
"messageNotifications": "Notificações de mensagens",
"sarAlerts": "Alertas SAR",
"discoveryNotifications": "Notificações de descoberta",
"updateNotifications": "Notificações de atualização",
"muteWhileAppIsOpen": "Silenciar com a app aberta",
"disableContacts": "Desativar contactos",
"enableSensorsTab": "Ativar separador Sensores",
"enableProfiles": "Ativar perfis",
"autoRouteRotation": "Rotação automática de rota",
"nearestRepeaterFallback": "Repetidor mais próximo como reserva",
"deleteAllStoredMessageHistory": "Apagar todo o histórico de mensagens",
"messageFontSize": "Tamanho da fonte de mensagens",
"rotateMapWithHeading": "Rodar mapa com direção",
"showMapDebugInfo": "Mostrar info de depuração do mapa",
"openMapInFullscreen": "Abrir mapa em ecrã inteiro",
"showSarMarkersLabel": "Mostrar marcadores SAR",
"displaySarMarkersOnTheMainMap": "Mostrar marcadores SAR no mapa principal",
"showAllContactTrailsLabel": "Mostrar todos os rastos de contactos",
"hideRepeatersOnMap": "Ocultar repetidores no mapa",
"setMapScale": "Definir escala do mapa",
"customMapScaleSaved": "Escala de mapa personalizada guardada",
"voiceBitrate": "Taxa de bits de voz",
"voiceCompressor": "Compressor de voz",
"balancesQuietAndLoudSpeechLevels": "Equilibra níveis de fala suaves e altos",
"voiceLimiter": "Limitador de voz",
"preventsClippingPeaksBeforeEncoding": "Previne corte de picos antes da codificação",
"micAutoGain": "Ganho automático do microfone",
"letsTheRecorderAdjustInputLevel": "Permite ao gravador ajustar o nível de entrada",
"echoCancellation": "Cancelamento de eco",
"noiseSuppression": "Supressão de ruído",
"trimSilenceInVoiceMessages": "Cortar silêncio em mensagens de voz",
"compressor": "Compressor",
"limiter": "Limitador",
"autoGain": "Ganho automático",
"echoCancel": "Eco",
"noiseSuppress": "Ruído",
"silenceTrim": "Silêncio",
"maxImageSize": "Tamanho máximo de imagem",
"imageCompression": "Compressão de imagem",
"grayscale": "Escala de cinzentos",
"ultraMode": "Modo ultra",
"fastPrivateGpsUpdates": "Atualizações GPS privadas rápidas",
"movementThreshold": "Limiar de movimento",
"fastGpsMovementThreshold": "Limiar de movimento GPS rápido",
"fastGpsActiveuseInterval": "Intervalo de uso ativo GPS rápido",
"activeuseUpdateInterval": "Intervalo de atualização de uso ativo",
"repeatNearbyTraffic": "Repetir tráfego próximo",
"relayThroughRepeatersAcrossTheMesh": "Retransmitir através de repetidores na rede",
"nearbyOnlyWithoutRepeaterFlooding": "Apenas próximos, sem flood de repetidores",
"multihop": "Multi-salto",
"createProfile": "Criar perfil",
"renameProfile": "Renomear perfil",
"newProfile": "Novo perfil",
"manageProfiles": "Gerir perfis",
"enableProfilesToStartManagingThem": "Ative os perfis para começar a geri-los.",
"openMessage": "Abrir mensagem",
"jumpToTheRelatedSarMessage": "Ir para a mensagem SAR relacionada",
"removeSarMarker": "Remover marcador SAR",
"pleaseSelectADestinationToSendSarMarker": "Selecione um destino para enviar o marcador SAR",
"sarMarkerBroadcastToPublicChannel": "Marcador SAR transmitido no canal público",
"sarMarkerSentToRoom": "Marcador SAR enviado para a sala",
"loadFromGallery": "Carregar da galeria",
"replaceImage": "Substituir imagem",
"selectFromGallery": "Selecionar da galeria",
"team": "Equipa",
"found": "Encontrado",
"staging": "Área de reunião",
"object": "Objeto",
"quiet": "Silencioso",
"moderate": "Moderado",
"busy": "Ocupado",
"spectrumScanReturnedNoCandidateFrequencies": "A varredura de espectro não encontrou frequências candidatas",
"searchMessages": "Pesquisar mensagens",
"sendImageFromGallery": "Enviar imagem da galeria",
"takePhoto": "Tirar foto",
"dmOnly": "Apenas mensagem direta",
"allMessages": "Todas as mensagens",
"sendToPublicChannel": "Enviar para o canal público?",
"selectMarkerTypeAndDestination": "Selecione tipo de marcador e destino",
"noDestinationsAvailableLabel": "Nenhum destino disponível",
"image": "Imagem",
"format": "Formato",
"dimensions": "Dimensões",
"segments": "Segmentos",
"transfers": "Transferências",
"downloadedBy": "Transferido por",
"saveDiscoverySettings": "Guardar definições de descoberta",
"savePublicInfo": "Guardar informação pública",
"saveRadioSettings": "Guardar definições de rádio",
"savePath": "Guardar caminho",
"wipeDeviceData": "Apagar dados do dispositivo",
"wipeDevice": "Apagar dispositivo",
"destructiveDeviceActions": "Ações destrutivas do dispositivo.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Escolha uma predefinição ou ajuste as definições de rádio.",
"chooseTheNameAndLocationThisDeviceShares": "Escolha o nome e a localização que este dispositivo partilha.",
"availableSpaceOnThisDevice": "Espaço disponível neste dispositivo.",
"used": "Usado",
"total": "Total",
"renameValue": "Renomear valor",
"customizeFields": "Personalizar campos",
"livePreview": "Pré-visualização ao vivo",
"refreshSchedule": "Agenda de atualização",
"noResponse": "Sem resposta",
"refreshing": "A atualizar",
"unavailable": "Indisponível",
"pickARelayOrNodeToWatchInSensors": "Escolha um relay ou nó para observar.",
"publicKeyLabel": "Chave pública",
"alreadyInContacts": "Já nos contactos",
"connectToADeviceBeforeAddingContacts": "Conecte-se a um dispositivo antes de adicionar contactos",
"fromContacts": "Dos contactos",
"onlineOnly": "Apenas online",
"inBoth": "Em ambos",
"source": "Fonte",
"manualRouteEdit": "Edição manual de rota",
"observedMeshRoute": "Rota mesh observada",
"allMessagesCleared": "Todas as mensagens limpas",
"onlineTraceDatabaseCleared": "Base de dados de rastreio limpa",
"packetLogsCleared": "Registos de pacotes limpos",
"hexDataCopiedToClipboard": "Dados hex copiados para a área de transferência",
"developerModeEnabled": "Modo de programador ativado",
"developerModeDisabled": "Modo de programador desativado",
"clipboardIsEmpty": "A área de transferência está vazia",
"contactImported": "Contacto importado",
"contactLinkCopiedToClipboard": "Link do contacto copiado para a área de transferência",
"failedToExportContact": "Falha ao exportar contacto",
"noLogsToExport": "Sem registos para exportar",
"exportAsCsv": "Exportar como CSV",
"exportAsText": "Exportar como texto",
"receivedRfc3339": "Recebido (RFC3339)",
"buildTime": "Hora de compilação",
"downloadUrlNotAvailable": "URL de download não disponível",
"cannotOpenDownloadUrl": "Não é possível abrir o URL de download",
"updateCheckIsOnlyAvailableOnAndroid": "A verificação de atualizações só está disponível no Android",
"youAreRunningTheLatestVersion": "Está a usar a versão mais recente",
"updateAvailableButDownloadUrlNotFound": "Atualização disponível mas URL de download não encontrado",
"startTictactoe": "Iniciar Tic-Tac-Toe",
"tictactoeUnavailable": "Tic-Tac-Toe indisponível",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: oponente desconhecido",
"tictactoeWaitingForStart": "Tic-Tac-Toe: a aguardar início",
"acceptsShareLinks": "Aceita links partilhados",
"supportsRawHex": "Suporta hex bruto",
"clipboardfriendly": "Compatível com a área de transferência",
"captured": "Capturado",
"size": "Tamanho",
"noCustomChannelsToClear": "Sem canais personalizados para limpar.",
"noDeviceContactsToClear": "Sem contactos do dispositivo para limpar."
}

View File

@@ -563,5 +563,252 @@
"contactFailedToClearRoute": "Не удалось очистить маршрут: {error}",
"contactRouteCleared": "Маршрут очищен",
"contactRouteSet": "Маршрут задан: {route}",
"contactFailedToSetRoute": "Не удалось задать маршрут: {error}"
"contactFailedToSetRoute": "Не удалось задать маршрут: {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "Таймаут ACK",
"opcode": "Опкод",
"payload": "Полезная нагрузка",
"hops": "Хопы",
"hashSize": "Размер хеша",
"pathBytes": "Байты пути",
"selectedPath": "Выбранный путь",
"estimatedTx": "Ожидаемая передача",
"senderToReceipt": "От отправителя до получения",
"receivedCopies": "Полученные копии",
"retryCause": "Причина повтора",
"retryMode": "Режим повтора",
"retryResult": "Результат повтора",
"lastRetry": "Последний повтор",
"rxPackets": "RX-пакеты",
"mesh": "Mesh",
"rate": "Скорость",
"window": "Окно",
"posttxDelay": "Задержка после передачи",
"bandpass": "Полосовой",
"bandpassFilterVoice": "Полосовой фильтр голоса",
"frequency": "Частота",
"australia": "Австралия",
"australiaNarrow": "Австралия (Узкий)",
"australiaQld": "Австралия: QLD",
"australiaSaWa": "Австралия: SA, WA",
"newZealand": "Новая Зеландия",
"newZealandNarrow": "Новая Зеландия (Узкий)",
"switzerland": "Швейцария",
"portugal433": "Португалия 433",
"portugal868": "Португалия 868",
"czechRepublicNarrow": "Чехия (Узкий)",
"eu433mhzLongRange": "ЕС 433МГц (Дальний)",
"euukDeprecated": "ЕС/Великобритания (Устарело)",
"euukNarrow": "ЕС/Великобритания (Узкий)",
"usacanadaRecommended": "США/Канада (Рекомендуется)",
"vietnamDeprecated": "Вьетнам (Устарело)",
"vietnamNarrow": "Вьетнам (Узкий)",
"active": "Активно",
"addContact": "Добавить контакт",
"all": "Все",
"autoResolve": "Автоматическое разрешение",
"clearAllLabel": "Очистить все",
"clearRelays": "Очистить реле",
"clearFilters": "Очистить фильтры",
"clearRoute": "Очистить маршрут",
"clearMessages": "Очистить сообщения",
"clearScale": "Очистить масштаб",
"clearDiscoveries": "Очистить обнаружения",
"clearOnlineTraceDatabase": "Очистить базу трассировок",
"clearAllChannels": "Очистить все каналы",
"clearAllContacts": "Очистить все контакты",
"clearChannels": "Очистить каналы",
"clearContacts": "Очистить контакты",
"clearPathOnMaxRetry": "Очистить путь при макс. повторе",
"create": "Создать",
"custom": "Пользовательский",
"defaultValue": "По умолчанию",
"duplicate": "Дублировать",
"editName": "Редактировать имя",
"open": "Открыть",
"paste": "Вставить",
"preview": "Предпросмотр",
"remove": "Удалить",
"rename": "Переименовать",
"resolveAll": "Разрешить все",
"send": "Отправить",
"sendAnyway": "Отправить все равно",
"share": "Поделиться",
"shareContact": "Поделиться контактом",
"trace": "Трассировка",
"use": "Использовать",
"useSelectedFrequency": "Использовать выбранную частоту",
"discovery": "Обнаружение",
"discoverRepeaters": "Обнаружить повторители",
"discoverSensors": "Обнаружить датчики",
"repeaterDiscoverySent": "Обнаружение повторителей отправлено",
"sensorDiscoverySent": "Обнаружение датчиков отправлено",
"clearedPendingDiscoveries": "Ожидающие обнаружения очищены.",
"autoDiscovery": "Автоматическое обнаружение",
"enableAutomaticAdding": "Включить автоматическое добавление",
"autoaddRepeaters": "Автодобавление повторителей",
"autoaddRoomServers": "Автодобавление серверов комнат",
"autoaddSensors": "Автодобавление датчиков",
"autoaddUsers": "Автодобавление пользователей",
"overwriteOldestWhenFull": "Перезаписать старейшие при заполнении",
"storage": "Хранилище",
"dangerZone": "Опасная зона",
"profiles": "Профили",
"favourites": "Избранное",
"sensors": "Датчики",
"others": "Другие",
"gpsModule": "Модуль GPS",
"liveTraffic": "Живой трафик",
"repeatersMap": "Карта повторителей",
"spectrumScan": "Сканирование спектра",
"blePacketLogs": "Журналы BLE-пакетов",
"onlineTraceDatabase": "База трассировок",
"routePathByteSize": "Размер пути в байтах",
"messageNotifications": "Уведомления о сообщениях",
"sarAlerts": "SAR-оповещения",
"discoveryNotifications": "Уведомления об обнаружении",
"updateNotifications": "Уведомления об обновлениях",
"muteWhileAppIsOpen": "Без звука при открытом приложении",
"disableContacts": "Отключить контакты",
"enableSensorsTab": "Включить вкладку Датчики",
"enableProfiles": "Включить профили",
"autoRouteRotation": "Автоматическая ротация маршрута",
"nearestRepeaterFallback": "Ближайший повторитель как резерв",
"deleteAllStoredMessageHistory": "Удалить всю историю сообщений",
"messageFontSize": "Размер шрифта сообщений",
"rotateMapWithHeading": "Вращать карту по направлению",
"showMapDebugInfo": "Показать отладочную информацию карты",
"openMapInFullscreen": "Открыть карту на весь экран",
"showSarMarkersLabel": "Показать SAR-маркеры",
"displaySarMarkersOnTheMainMap": "Отображать SAR-маркеры на основной карте",
"showAllContactTrailsLabel": "Показать все следы контактов",
"hideRepeatersOnMap": "Скрыть повторители на карте",
"setMapScale": "Установить масштаб карты",
"customMapScaleSaved": "Пользовательский масштаб карты сохранён",
"voiceBitrate": "Битрейт голоса",
"voiceCompressor": "Компрессор голоса",
"balancesQuietAndLoudSpeechLevels": "Выравнивает тихую и громкую речь",
"voiceLimiter": "Лимитер голоса",
"preventsClippingPeaksBeforeEncoding": "Предотвращает обрезку пиков перед кодированием",
"micAutoGain": "Автоусиление микрофона",
"letsTheRecorderAdjustInputLevel": "Позволяет рекордеру регулировать уровень входа",
"echoCancellation": "Подавление эха",
"noiseSuppression": "Подавление шума",
"trimSilenceInVoiceMessages": "Обрезать тишину в голосовых сообщениях",
"compressor": "Компрессор",
"limiter": "Лимитер",
"autoGain": "Автоусиление",
"echoCancel": "Эхо",
"noiseSuppress": "Шум",
"silenceTrim": "Тишина",
"maxImageSize": "Максимальный размер изображения",
"imageCompression": "Сжатие изображения",
"grayscale": "Оттенки серого",
"ultraMode": "Режим ультра",
"fastPrivateGpsUpdates": "Быстрые приватные GPS-обновления",
"movementThreshold": "Порог движения",
"fastGpsMovementThreshold": "Порог движения быстрого GPS",
"fastGpsActiveuseInterval": "Интервал активного использования быстрого GPS",
"activeuseUpdateInterval": "Интервал обновления при активном использовании",
"repeatNearbyTraffic": "Повторять ближний трафик",
"relayThroughRepeatersAcrossTheMesh": "Ретранслировать через повторители по сети",
"nearbyOnlyWithoutRepeaterFlooding": "Только рядом, без flood повторителей",
"multihop": "Многохоповый",
"createProfile": "Создать профиль",
"renameProfile": "Переименовать профиль",
"newProfile": "Новый профиль",
"manageProfiles": "Управление профилями",
"enableProfilesToStartManagingThem": "Включите профили, чтобы начать управлять ими.",
"openMessage": "Открыть сообщение",
"jumpToTheRelatedSarMessage": "Перейти к связанному SAR-сообщению",
"removeSarMarker": "Удалить SAR-маркер",
"pleaseSelectADestinationToSendSarMarker": "Выберите назначение для отправки SAR-маркера",
"sarMarkerBroadcastToPublicChannel": "SAR-маркер отправлен в публичный канал",
"sarMarkerSentToRoom": "SAR-маркер отправлен в комнату",
"loadFromGallery": "Загрузить из галереи",
"replaceImage": "Заменить изображение",
"selectFromGallery": "Выбрать из галереи",
"team": "Команда",
"found": "Найдено",
"staging": "Место сбора",
"object": "Объект",
"quiet": "Тихо",
"moderate": "Умеренно",
"busy": "Занято",
"spectrumScanReturnedNoCandidateFrequencies": "Сканирование спектра не нашло частот-кандидатов",
"searchMessages": "Поиск сообщений",
"sendImageFromGallery": "Отправить изображение из галереи",
"takePhoto": "Сделать фото",
"dmOnly": "Только личные сообщения",
"allMessages": "Все сообщения",
"sendToPublicChannel": "Отправить в публичный канал?",
"selectMarkerTypeAndDestination": "Выберите тип маркера и назначение",
"noDestinationsAvailableLabel": "Нет доступных назначений",
"image": "Изображение",
"format": "Формат",
"dimensions": "Размеры",
"segments": "Сегменты",
"transfers": "Передачи",
"downloadedBy": "Загружено",
"saveDiscoverySettings": "Сохранить настройки обнаружения",
"savePublicInfo": "Сохранить публичную информацию",
"saveRadioSettings": "Сохранить настройки радио",
"savePath": "Сохранить путь",
"wipeDeviceData": "Стереть данные устройства",
"wipeDevice": "Стереть устройство",
"destructiveDeviceActions": "Деструктивные действия устройства.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Выберите пресет или настройте радио вручную.",
"chooseTheNameAndLocationThisDeviceShares": "Выберите имя и местоположение, которыми делится устройство.",
"availableSpaceOnThisDevice": "Доступное место на этом устройстве.",
"used": "Использовано",
"total": "Всего",
"renameValue": "Переименовать значение",
"customizeFields": "Настроить поля",
"livePreview": "Предпросмотр в реальном времени",
"refreshSchedule": "Расписание обновления",
"noResponse": "Нет ответа",
"refreshing": "Обновление",
"unavailable": "Недоступно",
"pickARelayOrNodeToWatchInSensors": "Выберите реле или узел для наблюдения.",
"publicKeyLabel": "Публичный ключ",
"alreadyInContacts": "Уже в контактах",
"connectToADeviceBeforeAddingContacts": "Подключитесь к устройству перед добавлением контактов",
"fromContacts": "Из контактов",
"onlineOnly": "Только онлайн",
"inBoth": "В обоих",
"source": "Источник",
"manualRouteEdit": "Ручное редактирование маршрута",
"observedMeshRoute": "Наблюдаемый mesh-маршрут",
"allMessagesCleared": "Все сообщения очищены",
"onlineTraceDatabaseCleared": "База трассировок очищена",
"packetLogsCleared": "Журналы пакетов очищены",
"hexDataCopiedToClipboard": "Hex-данные скопированы в буфер",
"developerModeEnabled": "Режим разработчика включён",
"developerModeDisabled": "Режим разработчика отключён",
"clipboardIsEmpty": "Буфер обмена пуст",
"contactImported": "Контакт импортирован",
"contactLinkCopiedToClipboard": "Ссылка на контакт скопирована в буфер",
"failedToExportContact": "Не удалось экспортировать контакт",
"noLogsToExport": "Нет журналов для экспорта",
"exportAsCsv": "Экспортировать как CSV",
"exportAsText": "Экспортировать как текст",
"receivedRfc3339": "Получено (RFC3339)",
"buildTime": "Время сборки",
"downloadUrlNotAvailable": "URL загрузки недоступен",
"cannotOpenDownloadUrl": "Не удаётся открыть URL загрузки",
"updateCheckIsOnlyAvailableOnAndroid": "Проверка обновлений доступна только на Android",
"youAreRunningTheLatestVersion": "Вы используете последнюю версию",
"updateAvailableButDownloadUrlNotFound": "Обновление доступно, но URL загрузки не найден",
"startTictactoe": "Начать Tic-Tac-Toe",
"tictactoeUnavailable": "Tic-Tac-Toe недоступно",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: противник неизвестен",
"tictactoeWaitingForStart": "Tic-Tac-Toe: ожидание начала",
"acceptsShareLinks": "Принимает общие ссылки",
"supportsRawHex": "Поддерживает raw hex",
"clipboardfriendly": "Удобно для буфера",
"captured": "Захвачено",
"size": "Размер",
"noCustomChannelsToClear": "Нет пользовательских каналов для очистки.",
"noDeviceContactsToClear": "Нет контактов устройства для очистки."
}

View File

@@ -511,8 +511,7 @@
"rawDump": "Surovi izpis",
"cannotRetryMissingRecipient": "Ponovnega pošiljanja ni mogoče: informacije o prejemniku manjkajo",
"voiceUnavailable": "Glas trenutno ni na voljo",
"requestingVoice": "Zahteva za glasom"
,
"requestingVoice": "Zahteva za glasom",
"device": "naprava",
"change": "Spremeni",
"wizardOverviewDescription": "Ta aplikacija združuje sporočanje MeshCore, terenske SAR posodobitve, zemljevide in orodja za napravo na enem mestu.",
@@ -564,5 +563,252 @@
"contactFailedToClearRoute": "Brisanje poti ni uspelo: {error}",
"contactRouteCleared": "Pot izbrisana",
"contactRouteSet": "Pot nastavljena: {route}",
"contactFailedToSetRoute": "Nastavitev poti ni uspela: {error}"
"contactFailedToSetRoute": "Nastavitev poti ni uspela: {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "Časovna omejitev ACK",
"opcode": "Opkoda",
"payload": "Vsebina",
"hops": "Skoki",
"hashSize": "Velikost zgoščevalne vrednosti",
"pathBytes": "Bajti poti",
"selectedPath": "Izbrana pot",
"estimatedTx": "Ocenjeni čas oddaje",
"senderToReceipt": "Od pošiljatelja do prejema",
"receivedCopies": "Prejete kopije",
"retryCause": "Razlog ponovitve",
"retryMode": "Način ponovitve",
"retryResult": "Rezultat ponovitve",
"lastRetry": "Zadnja ponovitev",
"rxPackets": "RX paketi",
"mesh": "Mesh",
"rate": "Hitrost",
"window": "Okno",
"posttxDelay": "Zakasnitev po oddaji",
"bandpass": "Pasovno sito",
"bandpassFilterVoice": "Pasovno sito za glas",
"frequency": "Frekvenca",
"australia": "Avstralija",
"australiaNarrow": "Avstralija (ozko)",
"australiaQld": "Avstralija: QLD",
"australiaSaWa": "Avstralija: SA, WA",
"newZealand": "Nova Zelandija",
"newZealandNarrow": "Nova Zelandija (ozko)",
"switzerland": "Švica",
"portugal433": "Portugalska 433",
"portugal868": "Portugalska 868",
"czechRepublicNarrow": "Češka (ozko)",
"eu433mhzLongRange": "EU 433MHz (dolg doseg)",
"euukDeprecated": "EU/UK (zastarelo)",
"euukNarrow": "EU/UK (ozko)",
"usacanadaRecommended": "ZDA/Kanada (priporočeno)",
"vietnamDeprecated": "Vietnam (zastarelo)",
"vietnamNarrow": "Vietnam (ozko)",
"active": "Aktivno",
"addContact": "Dodaj stik",
"all": "Vse",
"autoResolve": "Samodejno razreši",
"clearAllLabel": "Počisti vse",
"clearRelays": "Počisti posrednike",
"clearFilters": "Počisti filtre",
"clearRoute": "Počisti pot",
"clearMessages": "Počisti sporočila",
"clearScale": "Počisti merilo",
"clearDiscoveries": "Počisti odkritja",
"clearOnlineTraceDatabase": "Počisti bazo sledi",
"clearAllChannels": "Počisti vse kanale",
"clearAllContacts": "Počisti vse stike",
"clearChannels": "Počisti kanale",
"clearContacts": "Počisti stike",
"clearPathOnMaxRetry": "Počisti pot ob maks. ponovitvi",
"create": "Ustvari",
"custom": "Po meri",
"defaultValue": "Privzeto",
"duplicate": "Podvoji",
"editName": "Uredi ime",
"open": "Odpri",
"paste": "Prilepi",
"preview": "Predogled",
"remove": "Odstrani",
"rename": "Preimenuj",
"resolveAll": "Razreši vse",
"send": "Pošlji",
"sendAnyway": "Vseeno pošlji",
"share": "Deli",
"shareContact": "Deli stik",
"trace": "Sledenje",
"use": "Uporabi",
"useSelectedFrequency": "Uporabi izbrano frekvenco",
"discovery": "Odkrivanje",
"discoverRepeaters": "Odkrij posrednike",
"discoverSensors": "Odkrij senzorje",
"repeaterDiscoverySent": "Odkrivanje posrednikov poslano",
"sensorDiscoverySent": "Odkrivanje senzorjev poslano",
"clearedPendingDiscoveries": "Čakalna odkritja počiščena.",
"autoDiscovery": "Samodejno odkrivanje",
"enableAutomaticAdding": "Omogoči samodejno dodajanje",
"autoaddRepeaters": "Samodejno dodaj posrednike",
"autoaddRoomServers": "Samodejno dodaj strežnike sob",
"autoaddSensors": "Samodejno dodaj senzorje",
"autoaddUsers": "Samodejno dodaj uporabnike",
"overwriteOldestWhenFull": "Prepiši najstarejše ob polnem",
"storage": "Shramba",
"dangerZone": "Nevarno območje",
"profiles": "Profili",
"favourites": "Priljubljeni",
"sensors": "Senzorji",
"others": "Ostali",
"gpsModule": "GPS modul",
"liveTraffic": "Promet v živo",
"repeatersMap": "Zemljevid posrednikov",
"spectrumScan": "Spektralno skeniranje",
"blePacketLogs": "Dnevniki BLE paketov",
"onlineTraceDatabase": "Baza sledi",
"routePathByteSize": "Velikost poti v bajtih",
"messageNotifications": "Obvestila o sporočilih",
"sarAlerts": "SAR opozorila",
"discoveryNotifications": "Obvestila o odkritjih",
"updateNotifications": "Obvestila o posodobitvah",
"muteWhileAppIsOpen": "Utišaj ko je aplikacija odprta",
"disableContacts": "Onemogoči stike",
"enableSensorsTab": "Omogoči zavihek Senzorji",
"enableProfiles": "Omogoči profile",
"autoRouteRotation": "Samodejno kroženje poti",
"nearestRepeaterFallback": "Najbližji posrednik kot rezerva",
"deleteAllStoredMessageHistory": "Izbriši vso shranjeno zgodovino sporočil",
"messageFontSize": "Velikost pisave sporočil",
"rotateMapWithHeading": "Vrti zemljevid s smerjo",
"showMapDebugInfo": "Prikaži razhroščevalne info zemljevida",
"openMapInFullscreen": "Odpri zemljevid na cel zaslon",
"showSarMarkersLabel": "Prikaži SAR oznake",
"displaySarMarkersOnTheMainMap": "Prikaži SAR oznake na glavnem zemljevidu",
"showAllContactTrailsLabel": "Prikaži vse sledi stikov",
"hideRepeatersOnMap": "Skrij posrednike na zemljevidu",
"setMapScale": "Nastavi merilo zemljevida",
"customMapScaleSaved": "Merilo zemljevida shranjeno",
"voiceBitrate": "Bitna hitrost glasu",
"voiceCompressor": "Kompresor glasu",
"balancesQuietAndLoudSpeechLevels": "Izravnava tihe in glasne govorne ravni",
"voiceLimiter": "Omejevalnik glasu",
"preventsClippingPeaksBeforeEncoding": "Preprečuje rezanje vrhov pred kodiranjem",
"micAutoGain": "Samodejna ojačitev mikrofona",
"letsTheRecorderAdjustInputLevel": "Omogoči snemalniku prilagoditev vhodne ravni",
"echoCancellation": "Odstranjevanje odmeva",
"noiseSuppression": "Dušenje šuma",
"trimSilenceInVoiceMessages": "Obreži tišino v glasovnih sporočilih",
"compressor": "Kompresor",
"limiter": "Omejevalnik",
"autoGain": "Samodejna ojačitev",
"echoCancel": "Odmev",
"noiseSuppress": "Šum",
"silenceTrim": "Tišina",
"maxImageSize": "Največja velikost slike",
"imageCompression": "Stiskanje slik",
"grayscale": "Sivine",
"ultraMode": "Ultra način",
"fastPrivateGpsUpdates": "Hitri zasebni GPS podatki",
"movementThreshold": "Prag gibanja",
"fastGpsMovementThreshold": "Prag gibanja za hitri GPS",
"fastGpsActiveuseInterval": "Interval uporabe hitrega GPS-a",
"activeuseUpdateInterval": "Interval posodobitve pri aktivni uporabi",
"repeatNearbyTraffic": "Ponavljaj bližnji promet",
"relayThroughRepeatersAcrossTheMesh": "Posreduj skozi posrednike po mreži",
"nearbyOnlyWithoutRepeaterFlooding": "Samo bližnji, brez poplavljanja posrednikov",
"multihop": "Večskočno",
"createProfile": "Ustvari profil",
"renameProfile": "Preimenuj profil",
"newProfile": "Nov profil",
"manageProfiles": "Upravljaj profile",
"enableProfilesToStartManagingThem": "Omogočite profile, da jih začnete upravljati.",
"openMessage": "Odpri sporočilo",
"jumpToTheRelatedSarMessage": "Skoči na povezano SAR sporočilo",
"removeSarMarker": "Odstrani SAR oznako",
"pleaseSelectADestinationToSendSarMarker": "Izberite cilj za pošiljanje SAR oznake",
"sarMarkerBroadcastToPublicChannel": "SAR oznaka poslana na javni kanal",
"sarMarkerSentToRoom": "SAR oznaka poslana v sobo",
"loadFromGallery": "Naloži iz galerije",
"replaceImage": "Zamenjaj sliko",
"selectFromGallery": "Izberi iz galerije",
"team": "Ekipa",
"found": "Najdeno",
"staging": "Zbirno mesto",
"object": "Predmet",
"quiet": "Tiho",
"moderate": "Zmerno",
"busy": "Zasedeno",
"spectrumScanReturnedNoCandidateFrequencies": "Spektralno skeniranje ni našlo primernih frekvenc",
"searchMessages": "Iskanje sporočil",
"sendImageFromGallery": "Pošlji sliko iz galerije",
"takePhoto": "Posnemi fotografijo",
"dmOnly": "Samo neposredno",
"allMessages": "Vsa sporočila",
"sendToPublicChannel": "Pošlji na javni kanal?",
"selectMarkerTypeAndDestination": "Izberite vrsto oznake in cilj",
"noDestinationsAvailableLabel": "Ni razpoložljivih ciljev",
"image": "Slika",
"format": "Format",
"dimensions": "Dimenzije",
"segments": "Segmenti",
"transfers": "Prenosi",
"downloadedBy": "Preneseno od",
"saveDiscoverySettings": "Shrani nastavitve odkrivanja",
"savePublicInfo": "Shrani javne podatke",
"saveRadioSettings": "Shrani radijske nastavitve",
"savePath": "Shrani pot",
"wipeDeviceData": "Izbriši podatke naprave",
"wipeDevice": "Izbriši napravo",
"destructiveDeviceActions": "Destruktivna dejanja na napravi.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Izberite prednastavitev ali natančno prilagodite radijske nastavitve.",
"chooseTheNameAndLocationThisDeviceShares": "Izberite ime in lokacijo, ki ju naprava deli.",
"availableSpaceOnThisDevice": "Razpoložljiv prostor na tej napravi.",
"used": "Uporabljeno",
"total": "Skupaj",
"renameValue": "Preimenuj vrednost",
"customizeFields": "Prilagodi polja",
"livePreview": "Predogled v živo",
"refreshSchedule": "Interval osvežitve",
"noResponse": "Ni odgovora",
"refreshing": "Osvežujem",
"unavailable": "Nedostopno",
"pickARelayOrNodeToWatchInSensors": "Izberite posrednika ali vozlišče za opazovanje.",
"publicKeyLabel": "Javni ključ",
"alreadyInContacts": "Že med stiki",
"connectToADeviceBeforeAddingContacts": "Povežite se z napravo pred dodajanjem stikov",
"fromContacts": "Iz stikov",
"onlineOnly": "Samo povezani",
"inBoth": "V obeh",
"source": "Vir",
"manualRouteEdit": "Ročno urejanje poti",
"observedMeshRoute": "Opazovana mrežna pot",
"allMessagesCleared": "Vsa sporočila počiščena",
"onlineTraceDatabaseCleared": "Baza sledi počiščena",
"packetLogsCleared": "Dnevniki paketov počiščeni",
"hexDataCopiedToClipboard": "Hex podatki kopirani v odložišče",
"developerModeEnabled": "Razvojni način omogočen",
"developerModeDisabled": "Razvojni način onemogočen",
"clipboardIsEmpty": "Odložišče je prazno",
"contactImported": "Stik uvožen",
"contactLinkCopiedToClipboard": "Povezava na stik kopirana v odložišče",
"failedToExportContact": "Izvoz stika ni uspel",
"noLogsToExport": "Ni dnevnikov za izvoz",
"exportAsCsv": "Izvozi kot CSV",
"exportAsText": "Izvozi kot besedilo",
"receivedRfc3339": "Prejeto (RFC3339)",
"buildTime": "Čas gradnje",
"downloadUrlNotAvailable": "URL za prenos ni na voljo",
"cannotOpenDownloadUrl": "URL za prenos ni mogoče odpreti",
"updateCheckIsOnlyAvailableOnAndroid": "Preverjanje posodobitev je na voljo le na Androidu",
"youAreRunningTheLatestVersion": "Uporabljate najnovejšo različico",
"updateAvailableButDownloadUrlNotFound": "Posodobitev na voljo, a URL za prenos ni najden",
"startTictactoe": "Začni Tic-Tac-Toe",
"tictactoeUnavailable": "Tic-Tac-Toe ni na voljo",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: nasprotnik neznan",
"tictactoeWaitingForStart": "Tic-Tac-Toe: čakanje na začetek",
"acceptsShareLinks": "Sprejema deljene povezave",
"supportsRawHex": "Podpira surove hex",
"clipboardfriendly": "Primerno za odložišče",
"captured": "Zajeto",
"size": "Velikost",
"noCustomChannelsToClear": "Ni prilagojenih kanalov za brisanje.",
"noDeviceContactsToClear": "Ni stikov naprave za brisanje."
}

View File

@@ -2328,5 +2328,252 @@
"contactFailedToClearRoute": "Rota temizlenemedi: {error}",
"contactRouteCleared": "Rota temizlendi",
"contactRouteSet": "Rota ayarlandı: {route}",
"contactFailedToSetRoute": "Rota ayarlanamadı: {error}"
"contactFailedToSetRoute": "Rota ayarlanamadı: {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "ACK zaman aşımı",
"opcode": "Opcode",
"payload": "Yük",
"hops": "Atlama",
"hashSize": "Hash boyutu",
"pathBytes": "Yol baytları",
"selectedPath": "Seçilen yol",
"estimatedTx": "Tahmini gönderim",
"senderToReceipt": "Gönderenden alındıya",
"receivedCopies": "Alınan kopyalar",
"retryCause": "Yeniden deneme nedeni",
"retryMode": "Yeniden deneme modu",
"retryResult": "Yeniden deneme sonucu",
"lastRetry": "Son deneme",
"rxPackets": "RX paketleri",
"mesh": "Mesh",
"rate": "Hız",
"window": "Pencere",
"posttxDelay": "Gönderim sonrası gecikme",
"bandpass": "Bant geçiren",
"bandpassFilterVoice": "Bant geçiren filtre ses",
"frequency": "Frekans",
"australia": "Avustralya",
"australiaNarrow": "Avustralya (Dar)",
"australiaQld": "Avustralya: QLD",
"australiaSaWa": "Avustralya: SA, WA",
"newZealand": "Yeni Zelanda",
"newZealandNarrow": "Yeni Zelanda (Dar)",
"switzerland": "İsviçre",
"portugal433": "Portekiz 433",
"portugal868": "Portekiz 868",
"czechRepublicNarrow": "Çek Cumhuriyeti (Dar)",
"eu433mhzLongRange": "AB 433MHz (Uzun Menzil)",
"euukDeprecated": "AB/BK (Kullanımdan kalktı)",
"euukNarrow": "AB/BK (Dar)",
"usacanadaRecommended": "ABD/Kanada (Önerilen)",
"vietnamDeprecated": "Vietnam (Kullanımdan kalktı)",
"vietnamNarrow": "Vietnam (Dar)",
"active": "Aktif",
"addContact": "Kişi ekle",
"all": "Tümü",
"autoResolve": "Otomatik çöz",
"clearAllLabel": "Tümünü temizle",
"clearRelays": "Röleleri temizle",
"clearFilters": "Filtreleri temizle",
"clearRoute": "Rotayı temizle",
"clearMessages": "Mesajları temizle",
"clearScale": "Ölçeği temizle",
"clearDiscoveries": "Keşifleri temizle",
"clearOnlineTraceDatabase": "Çevrimiçi iz veritabanını temizle",
"clearAllChannels": "Tüm kanalları temizle",
"clearAllContacts": "Tüm kişileri temizle",
"clearChannels": "Kanalları temizle",
"clearContacts": "Kişileri temizle",
"clearPathOnMaxRetry": "Maks denemede yolu temizle",
"create": "Oluştur",
"custom": "Özel",
"defaultValue": "Varsayılan",
"duplicate": "Çoğalt",
"editName": "Adı düzenle",
"open": "Aç",
"paste": "Yapıştır",
"preview": "Önizleme",
"remove": "Kaldır",
"rename": "Yeniden adlandır",
"resolveAll": "Tümünü çöz",
"send": "Gönder",
"sendAnyway": "Yine de gönder",
"share": "Paylaş",
"shareContact": "Kişiyi paylaş",
"trace": "İz",
"use": "Kullan",
"useSelectedFrequency": "Seçilen frekansı kullan",
"discovery": "Keşif",
"discoverRepeaters": "Tekrarlayıcıları keşfet",
"discoverSensors": "Sensörleri keşfet",
"repeaterDiscoverySent": "Tekrarlayıcı keşfi gönderildi",
"sensorDiscoverySent": "Sensör keşfi gönderildi",
"clearedPendingDiscoveries": "Bekleyen keşifler temizlendi.",
"autoDiscovery": "Otomatik keşif",
"enableAutomaticAdding": "Otomatik eklemeyi etkinleştir",
"autoaddRepeaters": "Tekrarlayıcıları otomatik ekle",
"autoaddRoomServers": "Oda sunucularını otomatik ekle",
"autoaddSensors": "Sensörleri otomatik ekle",
"autoaddUsers": "Kullanıcıları otomatik ekle",
"overwriteOldestWhenFull": "Dolduğunda en eskiyi üzerine yaz",
"storage": "Depolama",
"dangerZone": "Tehlikeli bölge",
"profiles": "Profiller",
"favourites": "Favoriler",
"sensors": "Sensörler",
"others": "Diğerleri",
"gpsModule": "GPS Modülü",
"liveTraffic": "Canlı trafik",
"repeatersMap": "Tekrarlayıcılar haritası",
"spectrumScan": "Spektrum taraması",
"blePacketLogs": "BLE paket günlükleri",
"onlineTraceDatabase": "Çevrimiçi iz veritabanı",
"routePathByteSize": "Yol bayt boyutu",
"messageNotifications": "Mesaj bildirimleri",
"sarAlerts": "SAR uyarıları",
"discoveryNotifications": "Keşif bildirimleri",
"updateNotifications": "Güncelleme bildirimleri",
"muteWhileAppIsOpen": "Uygulama açıkken sessize al",
"disableContacts": "Kişileri devre dışı bırak",
"enableSensorsTab": "Sensörler sekmesini etkinleştir",
"enableProfiles": "Profilleri etkinleştir",
"autoRouteRotation": "Otomatik rota rotasyonu",
"nearestRepeaterFallback": "En yakın tekrarlayıcıya geri dönüş",
"deleteAllStoredMessageHistory": "Tüm mesaj geçmişini sil",
"messageFontSize": "Mesaj yazı tipi boyutu",
"rotateMapWithHeading": "Haritayı yön ile döndür",
"showMapDebugInfo": "Harita hata ayıklama bilgisini göster",
"openMapInFullscreen": "Haritayı tam ekranda aç",
"showSarMarkersLabel": "SAR işaretçilerini göster",
"displaySarMarkersOnTheMainMap": "SAR işaretçilerini ana haritada göster",
"showAllContactTrailsLabel": "Tüm kişi izlerini göster",
"hideRepeatersOnMap": "Haritada tekrarlayıcıları gizle",
"setMapScale": "Harita ölçeğini ayarla",
"customMapScaleSaved": "Özel harita ölçeği kaydedildi",
"voiceBitrate": "Ses bit hızı",
"voiceCompressor": "Ses sıkıştırıcı",
"balancesQuietAndLoudSpeechLevels": "Sessiz ve yüksek konuşma seviyelerini dengeler",
"voiceLimiter": "Ses sınırlayıcı",
"preventsClippingPeaksBeforeEncoding": "Kodlama öncesi tepe kırpmasını önler",
"micAutoGain": "Mikrofon otomatik kazancı",
"letsTheRecorderAdjustInputLevel": "Kaydedicinin giriş seviyesini ayarlamasına izin verir",
"echoCancellation": "Yankı iptali",
"noiseSuppression": "Gürültü bastırma",
"trimSilenceInVoiceMessages": "Sesli mesajlarda sessizliği kırp",
"compressor": "Sıkıştırıcı",
"limiter": "Sınırlayıcı",
"autoGain": "Otomatik kazanç",
"echoCancel": "Yankı",
"noiseSuppress": "Gürültü",
"silenceTrim": "Sessizlik",
"maxImageSize": "Maksimum resim boyutu",
"imageCompression": "Resim sıkıştırma",
"grayscale": "Gri tonlama",
"ultraMode": "Ultra mod",
"fastPrivateGpsUpdates": "Hızlı özel GPS güncellemeleri",
"movementThreshold": "Hareket eşiği",
"fastGpsMovementThreshold": "Hızlı GPS hareket eşiği",
"fastGpsActiveuseInterval": "Hızlı GPS aktif kullanım aralığı",
"activeuseUpdateInterval": "Aktif kullanım güncelleme aralığı",
"repeatNearbyTraffic": "Yakın trafiği tekrarla",
"relayThroughRepeatersAcrossTheMesh": "Ağ üzerindeki tekrarlayıcılar aracılığıyla ilet",
"nearbyOnlyWithoutRepeaterFlooding": "Yalnızca yakın, tekrarlayıcı taşkını olmadan",
"multihop": "Çok atlamalı",
"createProfile": "Profil oluştur",
"renameProfile": "Profili yeniden adlandır",
"newProfile": "Yeni profil",
"manageProfiles": "Profilleri yönet",
"enableProfilesToStartManagingThem": "Profilleri yönetmeye başlamak için etkinleştirin.",
"openMessage": "Mesajı aç",
"jumpToTheRelatedSarMessage": "İlgili SAR mesajına git",
"removeSarMarker": "SAR işaretçisini kaldır",
"pleaseSelectADestinationToSendSarMarker": "SAR işaretçisi göndermek için bir hedef seçin",
"sarMarkerBroadcastToPublicChannel": "SAR işaretçisi genel kanala yayınlandı",
"sarMarkerSentToRoom": "SAR işaretçisi odaya gönderildi",
"loadFromGallery": "Galeriden yükle",
"replaceImage": "Resmi değiştir",
"selectFromGallery": "Galeriden seç",
"team": "Takım",
"found": "Bulundu",
"staging": "Toplanma alanı",
"object": "Nesne",
"quiet": "Sessiz",
"moderate": "Orta",
"busy": "Meşgul",
"spectrumScanReturnedNoCandidateFrequencies": "Spektrum taraması aday frekans bulamadı",
"searchMessages": "Mesajlarda ara",
"sendImageFromGallery": "Galeriden resim gönder",
"takePhoto": "Fotoğraf çek",
"dmOnly": "Yalnızca DM",
"allMessages": "Tüm mesajlar",
"sendToPublicChannel": "Genel kanala gönderilsin mi?",
"selectMarkerTypeAndDestination": "İşaretçi türünü ve hedefi seçin",
"noDestinationsAvailableLabel": "Kullanılabilir hedef yok",
"image": "Resim",
"format": "Biçim",
"dimensions": "Boyutlar",
"segments": "Segmentler",
"transfers": "Transferler",
"downloadedBy": "İndiren",
"saveDiscoverySettings": "Keşif ayarlarını kaydet",
"savePublicInfo": "Genel bilgileri kaydet",
"saveRadioSettings": "Radyo ayarlarını kaydet",
"savePath": "Yolu kaydet",
"wipeDeviceData": "Cihaz verilerini sil",
"wipeDevice": "Cihazı sil",
"destructiveDeviceActions": "Yıkıcı cihaz işlemleri.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Bir ön ayar seçin veya radyo ayarlarını ince ayarlayın.",
"chooseTheNameAndLocationThisDeviceShares": "Bu cihazın paylaştığı ad ve konumu seçin.",
"availableSpaceOnThisDevice": "Bu cihazda kullanılabilir alan.",
"used": "Kullanılan",
"total": "Toplam",
"renameValue": "Değeri yeniden adlandır",
"customizeFields": "Alanları özelleştir",
"livePreview": "Canlı önizleme",
"refreshSchedule": "Yenileme programı",
"noResponse": "Yanıt yok",
"refreshing": "Yenileniyor",
"unavailable": "Kullanılamaz",
"pickARelayOrNodeToWatchInSensors": "İzlemek için bir röle veya düğüm seçin.",
"publicKeyLabel": "Genel anahtar",
"alreadyInContacts": "Zaten kişilerde",
"connectToADeviceBeforeAddingContacts": "Kişi eklemeden önce bir cihaza bağlanın",
"fromContacts": "Kişilerden",
"onlineOnly": "Yalnızca çevrimiçi",
"inBoth": "Her ikisinde",
"source": "Kaynak",
"manualRouteEdit": "Manuel rota düzenle",
"observedMeshRoute": "Gözlemlenen mesh rotası",
"allMessagesCleared": "Tüm mesajlar temizlendi",
"onlineTraceDatabaseCleared": "Çevrimiçi iz veritabanı temizlendi",
"packetLogsCleared": "Paket günlükleri temizlendi",
"hexDataCopiedToClipboard": "Hex verisi panoya kopyalandı",
"developerModeEnabled": "Geliştirici modu etkinleştirildi",
"developerModeDisabled": "Geliştirici modu devre dışı bırakıldı",
"clipboardIsEmpty": "Pano boş",
"contactImported": "Kişi içe aktarıldı",
"contactLinkCopiedToClipboard": "Kişi bağlantısı panoya kopyalandı",
"failedToExportContact": "Kişi dışa aktarılamadı",
"noLogsToExport": "Dışa aktarılacak günlük yok",
"exportAsCsv": "CSV olarak dışa aktar",
"exportAsText": "Metin olarak dışa aktar",
"receivedRfc3339": "Alındı (RFC3339)",
"buildTime": "Derleme zamanı",
"downloadUrlNotAvailable": "İndirme URL'si mevcut değil",
"cannotOpenDownloadUrl": "İndirme URL'si açılamıyor",
"updateCheckIsOnlyAvailableOnAndroid": "Güncelleme kontrolü yalnızca Android'de kullanılabilir",
"youAreRunningTheLatestVersion": "En son sürümü kullanıyorsunuz",
"updateAvailableButDownloadUrlNotFound": "Güncelleme mevcut ancak indirme URL'si bulunamadı",
"startTictactoe": "Tic-Tac-Toe başlat",
"tictactoeUnavailable": "Tic-Tac-Toe kullanılamıyor",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: rakip bilinmiyor",
"tictactoeWaitingForStart": "Tic-Tac-Toe: başlangıç bekleniyor",
"acceptsShareLinks": "Paylaşım bağlantılarını kabul eder",
"supportsRawHex": "Ham hex destekler",
"clipboardfriendly": "Pano dostu",
"captured": "Yakalandı",
"size": "Boyut",
"noCustomChannelsToClear": "Temizlenecek özel kanal yok.",
"noDeviceContactsToClear": "Temizlenecek cihaz kişisi yok."
}

View File

@@ -2328,5 +2328,252 @@
"contactFailedToClearRoute": "Не вдалося очистити маршрут: {error}",
"contactRouteCleared": "Маршрут очищено",
"contactRouteSet": "Маршрут задано: {route}",
"contactFailedToSetRoute": "Не вдалося задати маршрут: {error}"
"contactFailedToSetRoute": "Не вдалося задати маршрут: {error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "Тайм-аут ACK",
"opcode": "Опкод",
"payload": "Корисне навантаження",
"hops": "Хопи",
"hashSize": "Розмір хешу",
"pathBytes": "Байти шляху",
"selectedPath": "Обраний шлях",
"estimatedTx": "Очікувана передача",
"senderToReceipt": "Від відправника до отримання",
"receivedCopies": "Отримані копії",
"retryCause": "Причина повтору",
"retryMode": "Режим повтору",
"retryResult": "Результат повтору",
"lastRetry": "Остання спроба",
"rxPackets": "RX-пакети",
"mesh": "Mesh",
"rate": "Швидкість",
"window": "Вікно",
"posttxDelay": "Затримка після передачі",
"bandpass": "Смуговий",
"bandpassFilterVoice": "Смуговий фільтр голосу",
"frequency": "Частота",
"australia": "Австралія",
"australiaNarrow": "Австралія (Вузький)",
"australiaQld": "Австралія: QLD",
"australiaSaWa": "Австралія: SA, WA",
"newZealand": "Нова Зеландія",
"newZealandNarrow": "Нова Зеландія (Вузький)",
"switzerland": "Швейцарія",
"portugal433": "Португалія 433",
"portugal868": "Португалія 868",
"czechRepublicNarrow": "Чехія (Вузький)",
"eu433mhzLongRange": "ЄС 433МГц (Далекий)",
"euukDeprecated": "ЄС/Великобританія (Застаріле)",
"euukNarrow": "ЄС/Великобританія (Вузький)",
"usacanadaRecommended": "США/Канада (Рекомендовано)",
"vietnamDeprecated": "В'єтнам (Застаріле)",
"vietnamNarrow": "В'єтнам (Вузький)",
"active": "Активно",
"addContact": "Додати контакт",
"all": "Усі",
"autoResolve": "Автоматичне вирішення",
"clearAllLabel": "Очистити все",
"clearRelays": "Очистити реле",
"clearFilters": "Очистити фільтри",
"clearRoute": "Очистити маршрут",
"clearMessages": "Очистити повідомлення",
"clearScale": "Очистити масштаб",
"clearDiscoveries": "Очистити виявлення",
"clearOnlineTraceDatabase": "Очистити базу трасувань",
"clearAllChannels": "Очистити всі канали",
"clearAllContacts": "Очистити всі контакти",
"clearChannels": "Очистити канали",
"clearContacts": "Очистити контакти",
"clearPathOnMaxRetry": "Очистити шлях при макс. спробі",
"create": "Створити",
"custom": "Користувацький",
"defaultValue": "За замовчуванням",
"duplicate": "Дублювати",
"editName": "Редагувати ім'я",
"open": "Відкрити",
"paste": "Вставити",
"preview": "Попередній перегляд",
"remove": "Видалити",
"rename": "Перейменувати",
"resolveAll": "Вирішити все",
"send": "Надіслати",
"sendAnyway": "Надіслати все одно",
"share": "Поділитися",
"shareContact": "Поділитися контактом",
"trace": "Трасування",
"use": "Використати",
"useSelectedFrequency": "Використати обрану частоту",
"discovery": "Виявлення",
"discoverRepeaters": "Виявити повторювачі",
"discoverSensors": "Виявити датчики",
"repeaterDiscoverySent": "Виявлення повторювачів надіслано",
"sensorDiscoverySent": "Виявлення датчиків надіслано",
"clearedPendingDiscoveries": "Очікувані виявлення очищено.",
"autoDiscovery": "Автоматичне виявлення",
"enableAutomaticAdding": "Увімкнути автоматичне додавання",
"autoaddRepeaters": "Автододавання повторювачів",
"autoaddRoomServers": "Автододавання серверів кімнат",
"autoaddSensors": "Автододавання датчиків",
"autoaddUsers": "Автододавання користувачів",
"overwriteOldestWhenFull": "Перезаписати найстаріші при заповненні",
"storage": "Сховище",
"dangerZone": "Небезпечна зона",
"profiles": "Профілі",
"favourites": "Обране",
"sensors": "Датчики",
"others": "Інші",
"gpsModule": "Модуль GPS",
"liveTraffic": "Живий трафік",
"repeatersMap": "Карта повторювачів",
"spectrumScan": "Сканування спектру",
"blePacketLogs": "Журнали BLE-пакетів",
"onlineTraceDatabase": "База трасувань",
"routePathByteSize": "Розмір шляху в байтах",
"messageNotifications": "Сповіщення про повідомлення",
"sarAlerts": "SAR-сповіщення",
"discoveryNotifications": "Сповіщення про виявлення",
"updateNotifications": "Сповіщення про оновлення",
"muteWhileAppIsOpen": "Без звуку при відкритому додатку",
"disableContacts": "Вимкнути контакти",
"enableSensorsTab": "Увімкнути вкладку Датчики",
"enableProfiles": "Увімкнути профілі",
"autoRouteRotation": "Автоматична ротація маршруту",
"nearestRepeaterFallback": "Найближчий повторювач як резерв",
"deleteAllStoredMessageHistory": "Видалити всю історію повідомлень",
"messageFontSize": "Розмір шрифту повідомлень",
"rotateMapWithHeading": "Обертати карту за напрямком",
"showMapDebugInfo": "Показати налагоджувальну інфо карти",
"openMapInFullscreen": "Відкрити карту на повний екран",
"showSarMarkersLabel": "Показати SAR-маркери",
"displaySarMarkersOnTheMainMap": "Показувати SAR-маркери на головній карті",
"showAllContactTrailsLabel": "Показати всі сліди контактів",
"hideRepeatersOnMap": "Сховати повторювачі на карті",
"setMapScale": "Встановити масштаб карти",
"customMapScaleSaved": "Користувацький масштаб карти збережено",
"voiceBitrate": "Бітрейт голосу",
"voiceCompressor": "Компресор голосу",
"balancesQuietAndLoudSpeechLevels": "Вирівнює тихий і гучний рівні мови",
"voiceLimiter": "Лімітер голосу",
"preventsClippingPeaksBeforeEncoding": "Запобігає обрізанню піків перед кодуванням",
"micAutoGain": "Автопідсилення мікрофона",
"letsTheRecorderAdjustInputLevel": "Дозволяє рекордеру налаштувати рівень входу",
"echoCancellation": "Придушення ехо",
"noiseSuppression": "Придушення шуму",
"trimSilenceInVoiceMessages": "Обрізати тишу в голосових повідомленнях",
"compressor": "Компресор",
"limiter": "Лімітер",
"autoGain": "Автопідсилення",
"echoCancel": "Ехо",
"noiseSuppress": "Шум",
"silenceTrim": "Тиша",
"maxImageSize": "Максимальний розмір зображення",
"imageCompression": "Стиснення зображення",
"grayscale": "Відтінки сірого",
"ultraMode": "Ультра режим",
"fastPrivateGpsUpdates": "Швидкі приватні GPS-оновлення",
"movementThreshold": "Поріг руху",
"fastGpsMovementThreshold": "Поріг руху швидкого GPS",
"fastGpsActiveuseInterval": "Інтервал активного використання швидкого GPS",
"activeuseUpdateInterval": "Інтервал оновлення при активному використанні",
"repeatNearbyTraffic": "Повторювати близький трафік",
"relayThroughRepeatersAcrossTheMesh": "Ретранслювати через повторювачі по мережі",
"nearbyOnlyWithoutRepeaterFlooding": "Лише поблизу, без flood повторювачів",
"multihop": "Багатохоповий",
"createProfile": "Створити профіль",
"renameProfile": "Перейменувати профіль",
"newProfile": "Новий профіль",
"manageProfiles": "Керувати профілями",
"enableProfilesToStartManagingThem": "Увімкніть профілі, щоб почати керувати ними.",
"openMessage": "Відкрити повідомлення",
"jumpToTheRelatedSarMessage": "Перейти до пов'язаного SAR-повідомлення",
"removeSarMarker": "Видалити SAR-маркер",
"pleaseSelectADestinationToSendSarMarker": "Виберіть призначення для надсилання SAR-маркера",
"sarMarkerBroadcastToPublicChannel": "SAR-маркер надіслано на публічний канал",
"sarMarkerSentToRoom": "SAR-маркер надіслано в кімнату",
"loadFromGallery": "Завантажити з галереї",
"replaceImage": "Замінити зображення",
"selectFromGallery": "Вибрати з галереї",
"team": "Команда",
"found": "Знайдено",
"staging": "Місце збору",
"object": "Об'єкт",
"quiet": "Тихо",
"moderate": "Помірно",
"busy": "Зайнято",
"spectrumScanReturnedNoCandidateFrequencies": "Сканування спектру не знайшло частот-кандидатів",
"searchMessages": "Пошук повідомлень",
"sendImageFromGallery": "Надіслати зображення з галереї",
"takePhoto": "Зробити фото",
"dmOnly": "Лише особисті повідомлення",
"allMessages": "Усі повідомлення",
"sendToPublicChannel": "Надіслати в публічний канал?",
"selectMarkerTypeAndDestination": "Виберіть тип маркера та призначення",
"noDestinationsAvailableLabel": "Немає доступних призначень",
"image": "Зображення",
"format": "Формат",
"dimensions": "Розміри",
"segments": "Сегменти",
"transfers": "Передачі",
"downloadedBy": "Завантажено",
"saveDiscoverySettings": "Зберегти налаштування виявлення",
"savePublicInfo": "Зберегти публічну інформацію",
"saveRadioSettings": "Зберегти налаштування радіо",
"savePath": "Зберегти шлях",
"wipeDeviceData": "Стерти дані пристрою",
"wipeDevice": "Стерти пристрій",
"destructiveDeviceActions": "Деструктивні дії пристрою.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Оберіть пресет або налаштуйте радіо вручну.",
"chooseTheNameAndLocationThisDeviceShares": "Оберіть ім'я та місцеположення, якими ділиться пристрій.",
"availableSpaceOnThisDevice": "Доступний простір на цьому пристрої.",
"used": "Використано",
"total": "Загалом",
"renameValue": "Перейменувати значення",
"customizeFields": "Налаштувати поля",
"livePreview": "Попередній перегляд наживо",
"refreshSchedule": "Розклад оновлення",
"noResponse": "Немає відповіді",
"refreshing": "Оновлення",
"unavailable": "Недоступно",
"pickARelayOrNodeToWatchInSensors": "Оберіть реле або вузол для спостереження.",
"publicKeyLabel": "Публічний ключ",
"alreadyInContacts": "Вже в контактах",
"connectToADeviceBeforeAddingContacts": "Підключіться до пристрою перед додаванням контактів",
"fromContacts": "З контактів",
"onlineOnly": "Лише онлайн",
"inBoth": "В обох",
"source": "Джерело",
"manualRouteEdit": "Ручне редагування маршруту",
"observedMeshRoute": "Спостережуваний mesh-маршрут",
"allMessagesCleared": "Усі повідомлення очищено",
"onlineTraceDatabaseCleared": "Базу трасувань очищено",
"packetLogsCleared": "Журнали пакетів очищено",
"hexDataCopiedToClipboard": "Hex-дані скопійовано в буфер",
"developerModeEnabled": "Режим розробника увімкнено",
"developerModeDisabled": "Режим розробника вимкнено",
"clipboardIsEmpty": "Буфер обміну порожній",
"contactImported": "Контакт імпортовано",
"contactLinkCopiedToClipboard": "Посилання на контакт скопійовано в буфер",
"failedToExportContact": "Не вдалося експортувати контакт",
"noLogsToExport": "Немає журналів для експорту",
"exportAsCsv": "Експортувати як CSV",
"exportAsText": "Експортувати як текст",
"receivedRfc3339": "Отримано (RFC3339)",
"buildTime": "Час збірки",
"downloadUrlNotAvailable": "URL завантаження недоступний",
"cannotOpenDownloadUrl": "Не вдається відкрити URL завантаження",
"updateCheckIsOnlyAvailableOnAndroid": "Перевірка оновлень доступна лише на Android",
"youAreRunningTheLatestVersion": "Ви використовуєте останню версію",
"updateAvailableButDownloadUrlNotFound": "Оновлення доступне, але URL завантаження не знайдено",
"startTictactoe": "Почати Tic-Tac-Toe",
"tictactoeUnavailable": "Tic-Tac-Toe недоступно",
"tictactoeOpponentUnknown": "Tic-Tac-Toe: суперник невідомий",
"tictactoeWaitingForStart": "Tic-Tac-Toe: очікування початку",
"acceptsShareLinks": "Приймає посилання для обміну",
"supportsRawHex": "Підтримує raw hex",
"clipboardfriendly": "Зручно для буфера",
"captured": "Захоплено",
"size": "Розмір",
"noCustomChannelsToClear": "Немає користувацьких каналів для очищення.",
"noDeviceContactsToClear": "Немає контактів пристрою для очищення."
}

View File

@@ -2228,80 +2228,55 @@
"description": "没有可发送的聊天联系人时的消息"
},
"reply": "回复",
"@reply": {
},
"@reply": {},
"technicalDetails": "技术细节",
"@technicalDetails": {
},
"@technicalDetails": {},
"messageTechnicalDetails": "消息技术细节",
"@messageTechnicalDetails": {
},
"@messageTechnicalDetails": {},
"linkQuality": "链路质量",
"@linkQuality": {
},
"@linkQuality": {},
"delivery": "投递",
"@delivery": {
},
"@delivery": {},
"status": "状态",
"@status": {
},
"@status": {},
"expectedAckTag": "预期的 ACK 标签",
"@expectedAckTag": {
},
"@expectedAckTag": {},
"roundTrip": "往返时间",
"@roundTrip": {
},
"@roundTrip": {},
"retryAttempt": "重试尝试",
"@retryAttempt": {
},
"@retryAttempt": {},
"floodFallback": "泛洪回退",
"@floodFallback": {
},
"@floodFallback": {},
"identity": "身份",
"@identity": {
},
"@identity": {},
"messageId": "消息 ID",
"@messageId": {
},
"@messageId": {},
"sender": "发送者",
"@sender": {
},
"@sender": {},
"senderKey": "发送者密钥",
"@senderKey": {
},
"@senderKey": {},
"recipient": "接收者",
"@recipient": {
},
"@recipient": {},
"recipientKey": "接收者密钥",
"@recipientKey": {
},
"@recipientKey": {},
"voice": "语音",
"@voice": {
},
"@voice": {},
"voiceId": "语音 ID",
"@voiceId": {
},
"@voiceId": {},
"envelope": "信封",
"@envelope": {
},
"@envelope": {},
"sessionProgress": "会话进度",
"@sessionProgress": {
},
"@sessionProgress": {},
"complete": "完成",
"@complete": {
},
"@complete": {},
"rawDump": "原始数据",
"@rawDump": {
},
"@rawDump": {},
"cannotRetryMissingRecipient": "无法重试:缺少接收者信息",
"@cannotRetryMissingRecipient": {
},
"@cannotRetryMissingRecipient": {},
"voiceUnavailable": "当前语音不可用",
"@voiceUnavailable": {
},
"@voiceUnavailable": {},
"requestingVoice": "正在请求语音",
"@requestingVoice": {
},
"@requestingVoice": {},
"device": "设备",
"change": "更改",
"wizardOverviewDescription": "此应用将 MeshCore 消息、SAR 现场更新、地图功能和设备工具整合到同一处。",
@@ -2353,5 +2328,252 @@
"contactFailedToClearRoute": "清除路由失败:{error}",
"contactRouteCleared": "路由已清除",
"contactRouteSet": "路由已设置:{route}",
"contactFailedToSetRoute": "设置路由失败:{error}"
"contactFailedToSetRoute": "设置路由失败:{error}",
"rssi": "RSSI",
"snr": "SNR",
"ackTimeout": "ACK 超时",
"opcode": "操作码",
"payload": "有效载荷",
"hops": "跳数",
"hashSize": "哈希大小",
"pathBytes": "路径字节",
"selectedPath": "选定路径",
"estimatedTx": "预计传输",
"senderToReceipt": "发送到接收",
"receivedCopies": "收到的副本",
"retryCause": "重试原因",
"retryMode": "重试模式",
"retryResult": "重试结果",
"lastRetry": "最后重试",
"rxPackets": "RX 数据包",
"mesh": "Mesh",
"rate": "速率",
"window": "窗口",
"posttxDelay": "发送后延迟",
"bandpass": "带通",
"bandpassFilterVoice": "带通语音滤波",
"frequency": "频率",
"australia": "澳大利亚",
"australiaNarrow": "澳大利亚(窄带)",
"australiaQld": "澳大利亚QLD",
"australiaSaWa": "澳大利亚SA, WA",
"newZealand": "新西兰",
"newZealandNarrow": "新西兰(窄带)",
"switzerland": "瑞士",
"portugal433": "葡萄牙 433",
"portugal868": "葡萄牙 868",
"czechRepublicNarrow": "捷克(窄带)",
"eu433mhzLongRange": "欧盟 433MHz远距离",
"euukDeprecated": "欧盟/英国(已弃用)",
"euukNarrow": "欧盟/英国(窄带)",
"usacanadaRecommended": "美国/加拿大(推荐)",
"vietnamDeprecated": "越南(已弃用)",
"vietnamNarrow": "越南(窄带)",
"active": "活动",
"addContact": "添加联系人",
"all": "全部",
"autoResolve": "自动解析",
"clearAllLabel": "清除全部",
"clearRelays": "清除中继",
"clearFilters": "清除过滤器",
"clearRoute": "清除路由",
"clearMessages": "清除消息",
"clearScale": "清除比例",
"clearDiscoveries": "清除发现",
"clearOnlineTraceDatabase": "清除在线追踪数据库",
"clearAllChannels": "清除所有频道",
"clearAllContacts": "清除所有联系人",
"clearChannels": "清除频道",
"clearContacts": "清除联系人",
"clearPathOnMaxRetry": "最大重试时清除路径",
"create": "创建",
"custom": "自定义",
"defaultValue": "默认",
"duplicate": "复制",
"editName": "编辑名称",
"open": "打开",
"paste": "粘贴",
"preview": "预览",
"remove": "移除",
"rename": "重命名",
"resolveAll": "解析全部",
"send": "发送",
"sendAnyway": "仍然发送",
"share": "分享",
"shareContact": "分享联系人",
"trace": "追踪",
"use": "使用",
"useSelectedFrequency": "使用选定频率",
"discovery": "发现",
"discoverRepeaters": "发现中继器",
"discoverSensors": "发现传感器",
"repeaterDiscoverySent": "中继器发现已发送",
"sensorDiscoverySent": "传感器发现已发送",
"clearedPendingDiscoveries": "已清除待处理的发现。",
"autoDiscovery": "自动发现",
"enableAutomaticAdding": "启用自动添加",
"autoaddRepeaters": "自动添加中继器",
"autoaddRoomServers": "自动添加房间服务器",
"autoaddSensors": "自动添加传感器",
"autoaddUsers": "自动添加用户",
"overwriteOldestWhenFull": "满时覆盖最旧的",
"storage": "存储",
"dangerZone": "危险区域",
"profiles": "配置文件",
"favourites": "收藏夹",
"sensors": "传感器",
"others": "其他",
"gpsModule": "GPS 模块",
"liveTraffic": "实时流量",
"repeatersMap": "中继器地图",
"spectrumScan": "频谱扫描",
"blePacketLogs": "BLE 数据包日志",
"onlineTraceDatabase": "在线追踪数据库",
"routePathByteSize": "路由路径字节大小",
"messageNotifications": "消息通知",
"sarAlerts": "SAR 警报",
"discoveryNotifications": "发现通知",
"updateNotifications": "更新通知",
"muteWhileAppIsOpen": "应用打开时静音",
"disableContacts": "禁用联系人",
"enableSensorsTab": "启用传感器标签",
"enableProfiles": "启用配置文件",
"autoRouteRotation": "自动路由轮换",
"nearestRepeaterFallback": "最近中继器回退",
"deleteAllStoredMessageHistory": "删除所有存储的消息历史",
"messageFontSize": "消息字体大小",
"rotateMapWithHeading": "随航向旋转地图",
"showMapDebugInfo": "显示地图调试信息",
"openMapInFullscreen": "全屏打开地图",
"showSarMarkersLabel": "显示 SAR 标记",
"displaySarMarkersOnTheMainMap": "在主地图上显示 SAR 标记",
"showAllContactTrailsLabel": "显示所有联系人轨迹",
"hideRepeatersOnMap": "在地图上隐藏中继器",
"setMapScale": "设置地图比例",
"customMapScaleSaved": "自定义地图比例已保存",
"voiceBitrate": "语音比特率",
"voiceCompressor": "语音压缩器",
"balancesQuietAndLoudSpeechLevels": "平衡安静和响亮的语音级别",
"voiceLimiter": "语音限制器",
"preventsClippingPeaksBeforeEncoding": "防止编码前的削波",
"micAutoGain": "麦克风自动增益",
"letsTheRecorderAdjustInputLevel": "让录音器调整输入级别",
"echoCancellation": "回声消除",
"noiseSuppression": "噪音抑制",
"trimSilenceInVoiceMessages": "修剪语音消息中的静音",
"compressor": "压缩器",
"limiter": "限制器",
"autoGain": "自动增益",
"echoCancel": "回声",
"noiseSuppress": "噪声",
"silenceTrim": "静音",
"maxImageSize": "最大图片大小",
"imageCompression": "图片压缩",
"grayscale": "灰度",
"ultraMode": "超级模式",
"fastPrivateGpsUpdates": "快速私有 GPS 更新",
"movementThreshold": "移动阈值",
"fastGpsMovementThreshold": "快速 GPS 移动阈值",
"fastGpsActiveuseInterval": "快速 GPS 活动使用间隔",
"activeuseUpdateInterval": "活动使用更新间隔",
"repeatNearbyTraffic": "转发附近流量",
"relayThroughRepeatersAcrossTheMesh": "通过网格中的中继器转发",
"nearbyOnlyWithoutRepeaterFlooding": "仅附近,无中继器泛洪",
"multihop": "多跳",
"createProfile": "创建配置文件",
"renameProfile": "重命名配置文件",
"newProfile": "新配置文件",
"manageProfiles": "管理配置文件",
"enableProfilesToStartManagingThem": "启用配置文件以开始管理它们。",
"openMessage": "打开消息",
"jumpToTheRelatedSarMessage": "跳转到相关的 SAR 消息",
"removeSarMarker": "移除 SAR 标记",
"pleaseSelectADestinationToSendSarMarker": "请选择目标以发送 SAR 标记",
"sarMarkerBroadcastToPublicChannel": "SAR 标记已广播到公共频道",
"sarMarkerSentToRoom": "SAR 标记已发送到房间",
"loadFromGallery": "从图库加载",
"replaceImage": "替换图片",
"selectFromGallery": "从图库选择",
"team": "团队",
"found": "已找到",
"staging": "集结区",
"object": "物体",
"quiet": "安静",
"moderate": "中等",
"busy": "繁忙",
"spectrumScanReturnedNoCandidateFrequencies": "频谱扫描未找到候选频率",
"searchMessages": "搜索消息",
"sendImageFromGallery": "从图库发送图片",
"takePhoto": "拍照",
"dmOnly": "仅私信",
"allMessages": "所有消息",
"sendToPublicChannel": "发送到公共频道?",
"selectMarkerTypeAndDestination": "选择标记类型和目标",
"noDestinationsAvailableLabel": "没有可用的目标",
"image": "图片",
"format": "格式",
"dimensions": "尺寸",
"segments": "分段",
"transfers": "传输",
"downloadedBy": "已下载",
"saveDiscoverySettings": "保存发现设置",
"savePublicInfo": "保存公共信息",
"saveRadioSettings": "保存无线电设置",
"savePath": "保存路径",
"wipeDeviceData": "擦除设备数据",
"wipeDevice": "擦除设备",
"destructiveDeviceActions": "破坏性设备操作。",
"chooseAPresetOrFinetuneCustomRadioSettings": "选择预设或微调自定义无线电设置。",
"chooseTheNameAndLocationThisDeviceShares": "选择此设备共享的名称和位置。",
"availableSpaceOnThisDevice": "此设备上的可用空间。",
"used": "已使用",
"total": "总计",
"renameValue": "重命名值",
"customizeFields": "自定义字段",
"livePreview": "实时预览",
"refreshSchedule": "刷新计划",
"noResponse": "无响应",
"refreshing": "刷新中",
"unavailable": "不可用",
"pickARelayOrNodeToWatchInSensors": "选择要在传感器中监视的中继或节点。",
"publicKeyLabel": "公钥",
"alreadyInContacts": "已在联系人中",
"connectToADeviceBeforeAddingContacts": "添加联系人前请连接到设备",
"fromContacts": "来自联系人",
"onlineOnly": "仅在线",
"inBoth": "两者中",
"source": "来源",
"manualRouteEdit": "手动路由编辑",
"observedMeshRoute": "观察到的网格路由",
"allMessagesCleared": "所有消息已清除",
"onlineTraceDatabaseCleared": "在线追踪数据库已清除",
"packetLogsCleared": "数据包日志已清除",
"hexDataCopiedToClipboard": "十六进制数据已复制到剪贴板",
"developerModeEnabled": "开发者模式已启用",
"developerModeDisabled": "开发者模式已禁用",
"clipboardIsEmpty": "剪贴板为空",
"contactImported": "联系人已导入",
"contactLinkCopiedToClipboard": "联系人链接已复制到剪贴板",
"failedToExportContact": "导出联系人失败",
"noLogsToExport": "没有日志可导出",
"exportAsCsv": "导出为 CSV",
"exportAsText": "导出为文本",
"receivedRfc3339": "收到 (RFC3339)",
"buildTime": "构建时间",
"downloadUrlNotAvailable": "下载链接不可用",
"cannotOpenDownloadUrl": "无法打开下载链接",
"updateCheckIsOnlyAvailableOnAndroid": "更新检查仅在 Android 上可用",
"youAreRunningTheLatestVersion": "您正在使用最新版本",
"updateAvailableButDownloadUrlNotFound": "有更新可用但未找到下载链接",
"startTictactoe": "开始井字棋",
"tictactoeUnavailable": "井字棋不可用",
"tictactoeOpponentUnknown": "井字棋:对手未知",
"tictactoeWaitingForStart": "井字棋:等待开始",
"acceptsShareLinks": "接受分享链接",
"supportsRawHex": "支持原始十六进制",
"clipboardfriendly": "剪贴板友好",
"captured": "已捕获",
"size": "大小",
"noCustomChannelsToClear": "没有自定义频道可清除。",
"noDeviceContactsToClear": "没有设备联系人可清除。"
}

View File

@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:geolocator/geolocator.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'providers/connection_provider.dart';
import 'providers/contacts_provider.dart';
import 'providers/messages_provider.dart';
@@ -23,14 +24,20 @@ import 'services/voice_player_service.dart';
import 'services/notification_service.dart';
import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart';
import 'services/profile_manager.dart';
import 'services/profile_workspace_coordinator.dart';
import 'services/profiles_feature_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';
import 'l10n/app_localizations.dart';
void main() {
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await WakelockPlus.enable();
runApp(const MeshCoreSarApp());
}
@@ -42,11 +49,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() {
@@ -55,6 +64,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
}
Future<void> _initializeApp() async {
final prefs = await SharedPreferences.getInstance();
final profilesEnabled = await ProfilesFeatureService.isEnabled();
final activeProfileId =
prefs.getString(ProfileManager.activeProfileIdKey) ?? 'default';
await ProfileStorageScope.bootstrap(
profilesEnabled: profilesEnabled,
activeProfileId: activeProfileId,
);
await _loadThemePreference();
await _loadLocalePreference();
@@ -66,6 +83,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 +99,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 +115,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
}
@@ -242,6 +285,13 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
),
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()),
ChangeNotifierProvider(
create: (_) {
final manager = ProfileManager();
manager.initialize();
return manager;
},
),
// Voice provider (packet reassembly + playback)
ChangeNotifierProvider(
@@ -294,6 +344,37 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
imageProvider: context.read<ip.ImageProvider>(),
),
),
ProxyProvider6<
ProfileManager,
AppProvider,
ConnectionProvider,
ContactsProvider,
MessagesProvider,
MapProvider,
ProfileWorkspaceCoordinator
>(
update:
(
context,
profileManager,
appProvider,
connectionProvider,
contactsProvider,
messagesProvider,
mapProvider,
previous,
) => ProfileWorkspaceCoordinator(
profileManager: profileManager,
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
messagesProvider: messagesProvider,
sensorsProvider: context.read<SensorsProvider>(),
mapProvider: mapProvider,
drawingProvider: context.read<DrawingProvider>(),
channelsProvider: context.read<ChannelsProvider>(),
appProvider: appProvider,
),
),
],
child: _buildMaterialApp(),
);
@@ -304,6 +385,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,528 @@
import 'package:flutter/foundation.dart';
import 'channel.dart';
@immutable
class DeviceConfigProfileSection {
final int? frequencyKhz;
final int? bandwidth;
final int? spreadingFactor;
final int? codingRate;
final bool? repeatEnabled;
final int? txPower;
final int? telemetryModes;
final int? advertLocationPolicy;
final int? multiAcks;
final bool? manualAddContacts;
final bool? autoAddUsers;
final bool? autoAddRepeaters;
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final double? publicLatitude;
final double? publicLongitude;
const DeviceConfigProfileSection({
this.frequencyKhz,
this.bandwidth,
this.spreadingFactor,
this.codingRate,
this.repeatEnabled,
this.txPower,
this.telemetryModes,
this.advertLocationPolicy,
this.multiAcks,
this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.publicLatitude,
this.publicLongitude,
});
bool get isEmpty =>
frequencyKhz == null &&
bandwidth == null &&
spreadingFactor == null &&
codingRate == null &&
repeatEnabled == null &&
txPower == null &&
telemetryModes == null &&
advertLocationPolicy == null &&
multiAcks == null &&
manualAddContacts == null &&
autoAddUsers == null &&
autoAddRepeaters == null &&
autoAddRoomServers == null &&
autoAddSensors == null &&
autoAddOverwriteOldest == null &&
publicLatitude == null &&
publicLongitude == null;
Map<String, dynamic> toJson() => {
'frequencyKhz': frequencyKhz,
'bandwidth': bandwidth,
'spreadingFactor': spreadingFactor,
'codingRate': codingRate,
'repeatEnabled': repeatEnabled,
'txPower': txPower,
'telemetryModes': telemetryModes,
'advertLocationPolicy': advertLocationPolicy,
'multiAcks': multiAcks,
'manualAddContacts': manualAddContacts,
'autoAddUsers': autoAddUsers,
'autoAddRepeaters': autoAddRepeaters,
'autoAddRoomServers': autoAddRoomServers,
'autoAddSensors': autoAddSensors,
'autoAddOverwriteOldest': autoAddOverwriteOldest,
'publicLatitude': publicLatitude,
'publicLongitude': publicLongitude,
};
factory DeviceConfigProfileSection.fromJson(Map<String, dynamic> json) {
return DeviceConfigProfileSection(
frequencyKhz: json['frequencyKhz'] as int?,
bandwidth: json['bandwidth'] as int?,
spreadingFactor: json['spreadingFactor'] as int?,
codingRate: json['codingRate'] as int?,
repeatEnabled: json['repeatEnabled'] as bool?,
txPower: json['txPower'] as int?,
telemetryModes: json['telemetryModes'] as int?,
advertLocationPolicy: json['advertLocationPolicy'] as int?,
multiAcks: json['multiAcks'] as int?,
manualAddContacts: json['manualAddContacts'] as bool?,
autoAddUsers: json['autoAddUsers'] as bool?,
autoAddRepeaters: json['autoAddRepeaters'] as bool?,
autoAddRoomServers: json['autoAddRoomServers'] as bool?,
autoAddSensors: json['autoAddSensors'] as bool?,
autoAddOverwriteOldest: json['autoAddOverwriteOldest'] as bool?,
publicLatitude: (json['publicLatitude'] as num?)?.toDouble(),
publicLongitude: (json['publicLongitude'] as num?)?.toDouble(),
);
}
}
@immutable
class AppSettingsProfileSection {
final bool? mapEnabled;
final bool? contactsEnabled;
final bool? sensorsEnabled;
final bool? voiceSilenceTrimmingEnabled;
final bool? voiceBandPassFilterEnabled;
final bool? voiceCompressorEnabled;
final bool? voiceLimiterEnabled;
final bool? voiceAutoGainEnabled;
final bool? voiceEchoCancellationEnabled;
final bool? voiceNoiseSuppressionEnabled;
final double? messageFontScale;
final bool? autoRouteRotationEnabled;
final bool? clearPathOnMaxRetry;
final bool? nearestRelayFallbackEnabled;
final int? voiceBitrate;
final int? routeHashSize;
final int? imageMaxSize;
final int? imageCompression;
final bool? imageGrayscale;
final bool? imageUltraMode;
final bool? showRxTxIndicators;
final bool? fastLocationUpdatesEnabled;
final double? fastLocationMovementThresholdMeters;
final int? fastLocationActiveCadenceSeconds;
const AppSettingsProfileSection({
this.mapEnabled,
this.contactsEnabled,
this.sensorsEnabled,
this.voiceSilenceTrimmingEnabled,
this.voiceBandPassFilterEnabled,
this.voiceCompressorEnabled,
this.voiceLimiterEnabled,
this.voiceAutoGainEnabled,
this.voiceEchoCancellationEnabled,
this.voiceNoiseSuppressionEnabled,
this.messageFontScale,
this.autoRouteRotationEnabled,
this.clearPathOnMaxRetry,
this.nearestRelayFallbackEnabled,
this.voiceBitrate,
this.routeHashSize,
this.imageMaxSize,
this.imageCompression,
this.imageGrayscale,
this.imageUltraMode,
this.showRxTxIndicators,
this.fastLocationUpdatesEnabled,
this.fastLocationMovementThresholdMeters,
this.fastLocationActiveCadenceSeconds,
});
bool get isEmpty =>
mapEnabled == null &&
contactsEnabled == null &&
sensorsEnabled == null &&
voiceSilenceTrimmingEnabled == null &&
voiceBandPassFilterEnabled == null &&
voiceCompressorEnabled == null &&
voiceLimiterEnabled == null &&
voiceAutoGainEnabled == null &&
voiceEchoCancellationEnabled == null &&
voiceNoiseSuppressionEnabled == null &&
messageFontScale == null &&
autoRouteRotationEnabled == null &&
clearPathOnMaxRetry == null &&
nearestRelayFallbackEnabled == null &&
voiceBitrate == null &&
routeHashSize == null &&
imageMaxSize == null &&
imageCompression == null &&
imageGrayscale == null &&
imageUltraMode == null &&
showRxTxIndicators == null &&
fastLocationUpdatesEnabled == null &&
fastLocationMovementThresholdMeters == null &&
fastLocationActiveCadenceSeconds == null;
Map<String, dynamic> toJson() => {
'mapEnabled': mapEnabled,
'contactsEnabled': contactsEnabled,
'sensorsEnabled': sensorsEnabled,
'voiceSilenceTrimmingEnabled': voiceSilenceTrimmingEnabled,
'voiceBandPassFilterEnabled': voiceBandPassFilterEnabled,
'voiceCompressorEnabled': voiceCompressorEnabled,
'voiceLimiterEnabled': voiceLimiterEnabled,
'voiceAutoGainEnabled': voiceAutoGainEnabled,
'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled,
'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled,
'messageFontScale': messageFontScale,
'autoRouteRotationEnabled': autoRouteRotationEnabled,
'clearPathOnMaxRetry': clearPathOnMaxRetry,
'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled,
'voiceBitrate': voiceBitrate,
'routeHashSize': routeHashSize,
'imageMaxSize': imageMaxSize,
'imageCompression': imageCompression,
'imageGrayscale': imageGrayscale,
'imageUltraMode': imageUltraMode,
'showRxTxIndicators': showRxTxIndicators,
'fastLocationUpdatesEnabled': fastLocationUpdatesEnabled,
'fastLocationMovementThresholdMeters': fastLocationMovementThresholdMeters,
'fastLocationActiveCadenceSeconds': fastLocationActiveCadenceSeconds,
};
factory AppSettingsProfileSection.fromJson(Map<String, dynamic> json) {
return AppSettingsProfileSection(
mapEnabled: json['mapEnabled'] as bool?,
contactsEnabled: json['contactsEnabled'] as bool?,
sensorsEnabled: json['sensorsEnabled'] as bool?,
voiceSilenceTrimmingEnabled: json['voiceSilenceTrimmingEnabled'] as bool?,
voiceBandPassFilterEnabled: json['voiceBandPassFilterEnabled'] as bool?,
voiceCompressorEnabled: json['voiceCompressorEnabled'] as bool?,
voiceLimiterEnabled: json['voiceLimiterEnabled'] as bool?,
voiceAutoGainEnabled: json['voiceAutoGainEnabled'] as bool?,
voiceEchoCancellationEnabled:
json['voiceEchoCancellationEnabled'] as bool?,
voiceNoiseSuppressionEnabled:
json['voiceNoiseSuppressionEnabled'] as bool?,
messageFontScale: (json['messageFontScale'] as num?)?.toDouble(),
autoRouteRotationEnabled: json['autoRouteRotationEnabled'] as bool?,
clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?,
nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?,
voiceBitrate: json['voiceBitrate'] as int?,
routeHashSize: json['routeHashSize'] as int?,
imageMaxSize: json['imageMaxSize'] as int?,
imageCompression: json['imageCompression'] as int?,
imageGrayscale: json['imageGrayscale'] as bool?,
imageUltraMode: json['imageUltraMode'] as bool?,
showRxTxIndicators: json['showRxTxIndicators'] as bool?,
fastLocationUpdatesEnabled: json['fastLocationUpdatesEnabled'] as bool?,
fastLocationMovementThresholdMeters:
(json['fastLocationMovementThresholdMeters'] as num?)?.toDouble(),
fastLocationActiveCadenceSeconds:
json['fastLocationActiveCadenceSeconds'] as int?,
);
}
}
@immutable
class MapWorkspaceProfileSection {
final Map<String, dynamic>? mapPrefs;
final List<Map<String, dynamic>> drawings;
final Map<String, dynamic>? currentTrail;
final List<Map<String, dynamic>> trailHistory;
final Map<String, dynamic>? importedTrail;
final bool? isTrailVisible;
final bool? showCadastralOverlay;
final bool? showForestRoadsOverlay;
final bool? showHikingTrailsOverlay;
final bool? showMainRoadsOverlay;
final bool? showHouseNumbersOverlay;
final bool? showFireHazardZonesOverlay;
final bool? showHistoricalFiresOverlay;
final bool? showFirebreaksOverlay;
final bool? showKrasFireZonesOverlay;
final bool? showPlaceNamesOverlay;
final bool? showMunicipalityBordersOverlay;
final bool? showAllContactTrails;
final bool? hideRepeatersOnMap;
const MapWorkspaceProfileSection({
this.mapPrefs,
this.drawings = const [],
this.currentTrail,
this.trailHistory = const [],
this.importedTrail,
this.isTrailVisible,
this.showCadastralOverlay,
this.showForestRoadsOverlay,
this.showHikingTrailsOverlay,
this.showMainRoadsOverlay,
this.showHouseNumbersOverlay,
this.showFireHazardZonesOverlay,
this.showHistoricalFiresOverlay,
this.showFirebreaksOverlay,
this.showKrasFireZonesOverlay,
this.showPlaceNamesOverlay,
this.showMunicipalityBordersOverlay,
this.showAllContactTrails,
this.hideRepeatersOnMap,
});
bool get isEmpty =>
mapPrefs == null &&
drawings.isEmpty &&
currentTrail == null &&
trailHistory.isEmpty &&
importedTrail == null &&
isTrailVisible == null &&
showCadastralOverlay == null &&
showForestRoadsOverlay == null &&
showHikingTrailsOverlay == null &&
showMainRoadsOverlay == null &&
showHouseNumbersOverlay == null &&
showFireHazardZonesOverlay == null &&
showHistoricalFiresOverlay == null &&
showFirebreaksOverlay == null &&
showKrasFireZonesOverlay == null &&
showPlaceNamesOverlay == null &&
showMunicipalityBordersOverlay == null &&
showAllContactTrails == null &&
hideRepeatersOnMap == null;
Map<String, dynamic> toJson() => {
'mapPrefs': mapPrefs,
'drawings': drawings,
'currentTrail': currentTrail,
'trailHistory': trailHistory,
'importedTrail': importedTrail,
'isTrailVisible': isTrailVisible,
'showCadastralOverlay': showCadastralOverlay,
'showForestRoadsOverlay': showForestRoadsOverlay,
'showHikingTrailsOverlay': showHikingTrailsOverlay,
'showMainRoadsOverlay': showMainRoadsOverlay,
'showHouseNumbersOverlay': showHouseNumbersOverlay,
'showFireHazardZonesOverlay': showFireHazardZonesOverlay,
'showHistoricalFiresOverlay': showHistoricalFiresOverlay,
'showFirebreaksOverlay': showFirebreaksOverlay,
'showKrasFireZonesOverlay': showKrasFireZonesOverlay,
'showPlaceNamesOverlay': showPlaceNamesOverlay,
'showMunicipalityBordersOverlay': showMunicipalityBordersOverlay,
'showAllContactTrails': showAllContactTrails,
'hideRepeatersOnMap': hideRepeatersOnMap,
};
factory MapWorkspaceProfileSection.fromJson(Map<String, dynamic> json) {
List<Map<String, dynamic>> decodeList(String key) {
final raw = json[key] as List<dynamic>? ?? const [];
return raw.whereType<Map<String, dynamic>>().toList();
}
return MapWorkspaceProfileSection(
mapPrefs: json['mapPrefs'] as Map<String, dynamic>?,
drawings: decodeList('drawings'),
currentTrail: json['currentTrail'] as Map<String, dynamic>?,
trailHistory: decodeList('trailHistory'),
importedTrail: json['importedTrail'] as Map<String, dynamic>?,
isTrailVisible: json['isTrailVisible'] as bool?,
showCadastralOverlay: json['showCadastralOverlay'] as bool?,
showForestRoadsOverlay: json['showForestRoadsOverlay'] as bool?,
showHikingTrailsOverlay: json['showHikingTrailsOverlay'] as bool?,
showMainRoadsOverlay: json['showMainRoadsOverlay'] as bool?,
showHouseNumbersOverlay: json['showHouseNumbersOverlay'] as bool?,
showFireHazardZonesOverlay: json['showFireHazardZonesOverlay'] as bool?,
showHistoricalFiresOverlay: json['showHistoricalFiresOverlay'] as bool?,
showFirebreaksOverlay: json['showFirebreaksOverlay'] as bool?,
showKrasFireZonesOverlay: json['showKrasFireZonesOverlay'] as bool?,
showPlaceNamesOverlay: json['showPlaceNamesOverlay'] as bool?,
showMunicipalityBordersOverlay:
json['showMunicipalityBordersOverlay'] as bool?,
showAllContactTrails: json['showAllContactTrails'] as bool?,
hideRepeatersOnMap: json['hideRepeatersOnMap'] as bool?,
);
}
}
@immutable
class ConfigProfileSections {
final DeviceConfigProfileSection? deviceConfig;
final AppSettingsProfileSection? appSettings;
final MapWorkspaceProfileSection? mapWorkspace;
final List<Channel> channels;
const ConfigProfileSections({
this.deviceConfig,
this.appSettings,
this.mapWorkspace,
this.channels = const [],
});
bool get isEmpty =>
(deviceConfig == null || deviceConfig!.isEmpty) &&
(appSettings == null || appSettings!.isEmpty) &&
(mapWorkspace == null || mapWorkspace!.isEmpty) &&
channels.isEmpty;
Map<String, dynamic> toJson() => {
'deviceConfig': deviceConfig?.toJson(),
'appSettings': appSettings?.toJson(),
'mapWorkspace': mapWorkspace?.toJson(),
'channels': channels.map((channel) => channel.toJson()).toList(),
};
factory ConfigProfileSections.fromJson(Map<String, dynamic> json) {
final rawChannels = json['channels'] as List<dynamic>? ?? const [];
return ConfigProfileSections(
deviceConfig: json['deviceConfig'] is Map<String, dynamic>
? DeviceConfigProfileSection.fromJson(
json['deviceConfig'] as Map<String, dynamic>,
)
: null,
appSettings: json['appSettings'] is Map<String, dynamic>
? AppSettingsProfileSection.fromJson(
json['appSettings'] as Map<String, dynamic>,
)
: null,
mapWorkspace: json['mapWorkspace'] is Map<String, dynamic>
? MapWorkspaceProfileSection.fromJson(
json['mapWorkspace'] as Map<String, dynamic>,
)
: null,
channels: rawChannels
.whereType<Map<String, dynamic>>()
.map(Channel.fromJson)
.toList(),
);
}
}
@immutable
class ConfigProfile {
static const String defaultProfileId = 'default';
final String id;
final String name;
final DateTime createdAt;
final DateTime updatedAt;
final String? notes;
final ConfigProfileSections sections;
const ConfigProfile({
required this.id,
required this.name,
required this.createdAt,
required this.updatedAt,
required this.sections,
this.notes,
});
bool get isDefault => id == defaultProfileId;
factory ConfigProfile.defaultProfile() {
final now = DateTime.now();
return ConfigProfile(
id: defaultProfileId,
name: 'Default',
createdAt: now,
updatedAt: now,
sections: const ConfigProfileSections(),
);
}
ConfigProfile copyWith({
String? id,
String? name,
DateTime? createdAt,
DateTime? updatedAt,
String? notes,
ConfigProfileSections? sections,
}) {
return ConfigProfile(
id: id ?? this.id,
name: name ?? this.name,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
notes: notes ?? this.notes,
sections: sections ?? this.sections,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'notes': notes,
'sections': sections.toJson(),
};
factory ConfigProfile.fromJson(Map<String, dynamic> json) {
return ConfigProfile(
id: json['id'] as String,
name: json['name'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
notes: json['notes'] as String?,
sections: ConfigProfileSections.fromJson(
json['sections'] as Map<String, dynamic>? ?? const {},
),
);
}
}
@immutable
class ProfileDiff {
final List<String> changedSections;
const ProfileDiff({required this.changedSections});
}
@immutable
class ProfileTransferRecord {
final String profileId;
final String direction;
final DateTime timestamp;
final String detail;
const ProfileTransferRecord({
required this.profileId,
required this.direction,
required this.timestamp,
required this.detail,
});
Map<String, dynamic> toJson() => {
'profileId': profileId,
'direction': direction,
'timestamp': timestamp.toIso8601String(),
'detail': detail,
};
factory ProfileTransferRecord.fromJson(Map<String, dynamic> json) {
return ProfileTransferRecord(
profileId: json['profileId'] as String,
direction: json['direction'] as String,
timestamp: DateTime.parse(json['timestamp'] as String),
detail: json['detail'] as String,
);
}
}

View File

@@ -141,6 +141,23 @@ class ContactRouteCodec {
);
}
static ParsedContactRoute direct({int hashSize = 1}) {
if (hashSize < 1 || hashSize > maxHashSize) {
throw ArgumentError.value(hashSize, 'hashSize', 'Must be 1, 2, or 3.');
}
final encodedPathLen = (hashSize - 1) << 6;
return ParsedContactRoute(
canonicalText: '',
hashSize: hashSize,
hopCount: 0,
encodedPathLen: encodedPathLen,
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
pathBytes: Uint8List(0),
paddedPathBytes: Uint8List(maxPathBytes),
);
}
static ParsedContactRoute? fromContact(Contact contact) {
if (!contact.routeHasPath || contact.routeHopCount == 0) {
return null;

View File

@@ -0,0 +1,151 @@
import 'dart:math' as math;
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../utils/custom_map_id.dart';
class CustomMapConfig {
final String filePath;
final String displayName;
final String mapId;
final int imageWidth;
final int imageHeight;
final double? metersPerPixel;
final LatLng? calibrationPointA;
final LatLng? calibrationPointB;
const CustomMapConfig({
required this.filePath,
required this.displayName,
required this.mapId,
required this.imageWidth,
required this.imageHeight,
this.metersPerPixel,
this.calibrationPointA,
this.calibrationPointB,
});
bool get isCalibrated => metersPerPixel != null && metersPerPixel! > 0;
double get _displayScale {
final heightScale = imageHeight / 90.0;
final widthScale = imageWidth / 180.0;
return math.max(1.0, math.max(heightScale, widthScale));
}
double get _displayHeight => imageHeight / _displayScale;
double get _displayWidth => imageWidth / _displayScale;
LatLngBounds get bounds =>
LatLngBounds(const LatLng(0, 0), LatLng(_displayHeight, _displayWidth));
LatLngBounds get displayBounds =>
LatLngBounds(LatLng(_displayHeight, 0), LatLng(0, _displayWidth));
LatLng toDisplayPoint(LatLng point) {
return LatLng(
_displayHeight - (point.latitude / _displayScale),
point.longitude / _displayScale,
);
}
LatLng fromDisplayPoint(LatLng point) {
return LatLng(
(_displayHeight - point.latitude) * _displayScale,
point.longitude * _displayScale,
);
}
Map<String, dynamic> toJson() {
return {
'filePath': filePath,
'displayName': displayName,
'mapId': mapId,
'imageWidth': imageWidth,
'imageHeight': imageHeight,
'metersPerPixel': metersPerPixel,
'calibrationPointA': calibrationPointA != null
? {
'lat': calibrationPointA!.latitude,
'lon': calibrationPointA!.longitude,
}
: null,
'calibrationPointB': calibrationPointB != null
? {
'lat': calibrationPointB!.latitude,
'lon': calibrationPointB!.longitude,
}
: null,
};
}
CustomMapConfig copyWith({
String? filePath,
String? displayName,
String? mapId,
int? imageWidth,
int? imageHeight,
double? metersPerPixel,
bool clearMetersPerPixel = false,
LatLng? calibrationPointA,
bool clearCalibrationPointA = false,
LatLng? calibrationPointB,
bool clearCalibrationPointB = false,
}) {
return CustomMapConfig(
filePath: filePath ?? this.filePath,
displayName: displayName ?? this.displayName,
mapId: normalizeCustomMapId(mapId) ?? this.mapId,
imageWidth: imageWidth ?? this.imageWidth,
imageHeight: imageHeight ?? this.imageHeight,
metersPerPixel: clearMetersPerPixel
? null
: (metersPerPixel ?? this.metersPerPixel),
calibrationPointA: clearCalibrationPointA
? null
: (calibrationPointA ?? this.calibrationPointA),
calibrationPointB: clearCalibrationPointB
? null
: (calibrationPointB ?? this.calibrationPointB),
);
}
static CustomMapConfig? fromJson(Map<String, dynamic>? json) {
if (json == null) return null;
final filePath = json['filePath'];
final displayName = json['displayName'];
final mapId = json['mapId'];
final imageWidth = json['imageWidth'];
final imageHeight = json['imageHeight'];
if (filePath is! String ||
displayName is! String ||
mapId is! String ||
imageWidth is! int ||
imageHeight is! int) {
return null;
}
LatLng? parsePoint(dynamic raw) {
if (raw is! Map<String, dynamic>) return null;
final lat = raw['lat'];
final lon = raw['lon'];
if (lat is! num || lon is! num) return null;
return LatLng(lat.toDouble(), lon.toDouble());
}
final metersPerPixel = json['metersPerPixel'];
return CustomMapConfig(
filePath: filePath,
displayName: displayName,
mapId: normalizeCustomMapId(mapId) ?? mapId,
imageWidth: imageWidth,
imageHeight: imageHeight,
metersPerPixel: metersPerPixel is num ? metersPerPixel.toDouble() : null,
calibrationPointA: parsePoint(json['calibrationPointA']),
calibrationPointB: parsePoint(json['calibrationPointB']),
);
}
}

View File

@@ -14,11 +14,11 @@ enum ConnectionMode {
/// Direct BLE connection to MeshCore device (default)
ble,
/// Act as SSE server - share BLE device with multiple clients
sseServer,
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
tcp,
/// USB serial connection (Android OTG or Web Serial API)
usb,
}
extension ConnectionModeExtension on ConnectionMode {
@@ -26,10 +26,10 @@ extension ConnectionModeExtension on ConnectionMode {
switch (this) {
case ConnectionMode.ble:
return 'Direct (BLE)';
case ConnectionMode.sseServer:
return 'Share Device (Server)';
case ConnectionMode.tcp:
return 'Direct (WiFi)';
case ConnectionMode.usb:
return 'Direct (USB)';
}
}
@@ -37,10 +37,10 @@ extension ConnectionModeExtension on ConnectionMode {
switch (this) {
case ConnectionMode.ble:
return 'Direct BLE connection to MeshCore device';
case ConnectionMode.sseServer:
return 'Share BLE device with multiple clients over network';
case ConnectionMode.tcp:
return 'Direct WiFi/TCP connection to MeshCore device';
case ConnectionMode.usb:
return 'USB serial connection to MeshCore device';
}
}
}
@@ -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

@@ -0,0 +1,11 @@
enum MapCoordinateSpace {
geo,
customMap;
static MapCoordinateSpace fromName(String? value) {
return MapCoordinateSpace.values.firstWhere(
(space) => space.name == value,
orElse: () => MapCoordinateSpace.geo,
);
}
}

View File

@@ -2,35 +2,26 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'map_coordinate_space.dart';
import '../utils/custom_map_id.dart';
/// Drawing shape type
enum DrawingShapeType {
line,
rectangle,
}
enum DrawingShapeType { line, rectangle }
/// Drawing color enum for compact network transmission
enum DrawingColor {
red, // 0
blue, // 1
green, // 2
yellow, // 3
orange, // 4
purple, // 5
pink, // 6
cyan, // 7
}
enum DrawingColor { red, blue, green, yellow, orange, purple, pink, cyan }
/// Drawing colors available for user selection
class DrawingColors {
static const List<Color> palette = [
Colors.red, // index 0
Colors.blue, // index 1
Colors.green, // index 2
Colors.yellow, // index 3
Colors.orange, // index 4
Colors.purple, // index 5
Colors.pink, // index 6
Colors.cyan, // index 7
Colors.red,
Colors.blue,
Colors.green,
Colors.yellow,
Colors.orange,
Colors.purple,
Colors.pink,
Colors.cyan,
];
static String colorToName(Color color) {
@@ -45,37 +36,36 @@ class DrawingColors {
return 'Unknown';
}
/// Convert Color to enum index for network transmission
static int colorToIndex(Color color) {
for (int i = 0; i < palette.length; i++) {
if (palette[i].toARGB32() == color.toARGB32()) {
return i;
}
}
return 0; // Default to red if not found
return 0;
}
/// Convert enum index to Color for network reception
static Color indexToColor(int index) {
if (index >= 0 && index < palette.length) {
return palette[index];
}
return palette[0]; // Default to red if invalid index
return palette[0];
}
}
/// Base class for map drawings
abstract class MapDrawing {
final String id;
final DrawingShapeType type;
final Color color;
final DateTime createdAt;
final String? senderName; // Name of sender (null if local drawing)
final bool isReceived; // True if drawing was received from another node
final String? messageId; // ID of the source message (for navigation)
final bool isShared; // Whether drawing has been broadcast over mesh
final bool isSent; // Whether this is a sent drawing (vs received)
final bool isHidden; // Temporary visibility toggle (session only, not persisted)
final String? senderName;
final bool isReceived;
final String? messageId;
final bool isShared;
final bool isSent;
final bool isHidden;
final MapCoordinateSpace coordinateSpace;
final String? mapId;
MapDrawing({
required this.id,
@@ -88,79 +78,79 @@ abstract class MapDrawing {
this.isShared = false,
this.isSent = false,
this.isHidden = false,
this.coordinateSpace = MapCoordinateSpace.geo,
this.mapId,
});
/// Convert to JSON for persistence
bool get isCustomMap => coordinateSpace == MapCoordinateSpace.customMap;
Map<String, dynamic> toJson();
/// Convert to JSON for network transmission (compact format)
/// Uses short field names and excludes createdAt to minimize message size
/// Sender will be fetched from packet metadata
Map<String, dynamic> toNetworkJson();
/// Parse network JSON (compact format)
/// senderName and messageId will be populated from packet metadata
static MapDrawing? fromNetworkJson(
Map<String, dynamic> json, {
String? senderName,
String? messageId,
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
String? mapId,
}) {
final typeNum = json['t'] as int?;
if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) {
if (typeNum == null ||
typeNum < 0 ||
typeNum >= DrawingShapeType.values.length) {
return null;
}
try {
final type = DrawingShapeType.values[typeNum];
switch (type) {
case DrawingShapeType.line:
return LineDrawing.fromNetworkJson(
json,
senderName: senderName,
messageId: messageId,
coordinateSpace: coordinateSpace,
mapId: mapId,
);
case DrawingShapeType.rectangle:
return RectangleDrawing.fromNetworkJson(
json,
senderName: senderName,
messageId: messageId,
coordinateSpace: coordinateSpace,
mapId: mapId,
);
}
} catch (e) {
} catch (_) {
return null;
}
}
/// Create from JSON
static MapDrawing? fromJson(Map<String, dynamic> json) {
final typeStr = json['type'] as String?;
if (typeStr == null) return null;
try {
final type = DrawingShapeType.values.firstWhere(
(e) => e.toString() == 'DrawingShapeType.$typeStr',
(value) => value.name == typeStr,
);
switch (type) {
case DrawingShapeType.line:
return LineDrawing.fromJson(json);
case DrawingShapeType.rectangle:
return RectangleDrawing.fromJson(json);
}
} catch (e) {
} catch (_) {
return null;
}
}
/// Get the center point of the drawing
LatLng getCenter();
/// Get the bounds of the drawing
LatLngBounds getBounds();
}
/// Line drawing on map
class LineDrawing extends MapDrawing {
final List<LatLng> points;
@@ -175,6 +165,8 @@ class LineDrawing extends MapDrawing {
super.isShared,
super.isSent,
super.isHidden,
super.coordinateSpace,
super.mapId,
}) : super(type: DrawingShapeType.line);
@override
@@ -184,31 +176,51 @@ class LineDrawing extends MapDrawing {
'type': type.name,
'color': color.toARGB32(),
'createdAt': createdAt.toIso8601String(),
'points': points.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
'points': points
.map((point) => {'lat': point.latitude, 'lon': point.longitude})
.toList(),
'isShared': isShared,
// Note: isHidden is not persisted - it's session-only
'coordinateSpace': coordinateSpace.name,
'mapId': normalizeCustomMapId(mapId),
};
}
@override
Map<String, dynamic> toNetworkJson() {
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points
// Points are encoded as flat array [lat1,lon1,lat2,lon2,...]
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
// Sender is fetched from packet metadata, not included in JSON
return {
final payload = <String, dynamic>{
't': type.index,
'c': DrawingColors.colorToIndex(color),
'p': points.expand((p) => [
double.parse(p.latitude.toStringAsFixed(5)),
double.parse(p.longitude.toStringAsFixed(5)),
]).toList(),
};
if (coordinateSpace == MapCoordinateSpace.customMap) {
payload['m'] = normalizeCustomMapId(mapId);
payload['p'] = points
.expand((point) => [point.latitude.round(), point.longitude.round()])
.toList();
return payload;
}
payload['p'] = points
.expand(
(point) => [
double.parse(point.latitude.toStringAsFixed(5)),
double.parse(point.longitude.toStringAsFixed(5)),
],
)
.toList();
return payload;
}
static LineDrawing fromJson(Map<String, dynamic> json) {
final pointsJson = json['points'] as List<dynamic>;
final points = pointsJson.map((p) => LatLng(p['lat'] as double, p['lon'] as double)).toList();
final points = pointsJson
.map(
(point) => LatLng(
(point['lat'] as num).toDouble(),
(point['lon'] as num).toDouble(),
),
)
.toList();
final senderName = json['sender'] as String?;
return LineDrawing(
@@ -217,8 +229,12 @@ class LineDrawing extends MapDrawing {
createdAt: DateTime.parse(json['createdAt'] as String),
points: points,
senderName: senderName,
isReceived: senderName != null, // Mark as received if sender is present
isReceived: senderName != null,
isShared: json['isShared'] as bool? ?? false,
coordinateSpace: MapCoordinateSpace.fromName(
json['coordinateSpace'] as String?,
),
mapId: normalizeCustomMapId(json['mapId'] as String?),
);
}
@@ -226,42 +242,60 @@ class LineDrawing extends MapDrawing {
Map<String, dynamic> json, {
String? senderName,
String? messageId,
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
String? mapId,
}) {
// Parse ultra-compact format
final pointsFlat = (json['p'] as List<dynamic>).cast<double>();
final flatPoints = (json['p'] as List<dynamic>).cast<num>();
final points = <LatLng>[];
for (int i = 0; i < pointsFlat.length; i += 2) {
points.add(LatLng(pointsFlat[i], pointsFlat[i + 1]));
for (int i = 0; i < flatPoints.length; i += 2) {
points.add(
LatLng(flatPoints[i].toDouble(), flatPoints[i + 1].toDouble()),
);
}
return LineDrawing(
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
id: DateTime.now().millisecondsSinceEpoch.toString(),
color: DrawingColors.indexToColor(json['c'] as int),
createdAt: DateTime.now(),
points: points,
senderName: senderName,
isReceived: true,
messageId: messageId, // Link to source message
isShared: false, // Received drawings are not marked as shared
messageId: messageId,
coordinateSpace: coordinateSpace,
mapId: normalizeCustomMapId(mapId),
);
}
/// Create a copy with updated points
LineDrawing copyWith({List<LatLng>? points}) {
LineDrawing copyWith({
List<LatLng>? points,
bool? isHidden,
bool? isShared,
bool? isReceived,
String? messageId,
String? senderName,
MapCoordinateSpace? coordinateSpace,
String? mapId,
}) {
return LineDrawing(
id: id,
color: color,
createdAt: createdAt,
points: points ?? this.points,
isHidden: isHidden ?? this.isHidden,
isShared: isShared ?? this.isShared,
isReceived: isReceived ?? this.isReceived,
messageId: messageId ?? this.messageId,
senderName: senderName ?? this.senderName,
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
mapId: mapId ?? this.mapId,
);
}
@override
LatLng getCenter() {
if (points.isEmpty) return LatLng(0, 0);
if (points.isEmpty) return const LatLng(0, 0);
if (points.length == 1) return points[0];
// Calculate center as average of all points
double sumLat = 0;
double sumLon = 0;
for (final point in points) {
@@ -273,8 +307,12 @@ class LineDrawing extends MapDrawing {
@override
LatLngBounds getBounds() {
if (points.isEmpty) return LatLngBounds(LatLng(0, 0), LatLng(0, 0));
if (points.length == 1) return LatLngBounds(points[0], points[0]);
if (points.isEmpty) {
return LatLngBounds(const LatLng(0, 0), const LatLng(0, 0));
}
if (points.length == 1) {
return LatLngBounds(points[0], points[0]);
}
double minLat = points[0].latitude;
double maxLat = points[0].latitude;
@@ -292,7 +330,6 @@ class LineDrawing extends MapDrawing {
}
}
/// Rectangle drawing on map
class RectangleDrawing extends MapDrawing {
final LatLng topLeft;
final LatLng bottomRight;
@@ -309,16 +346,17 @@ class RectangleDrawing extends MapDrawing {
super.isShared,
super.isSent,
super.isHidden,
super.coordinateSpace,
super.mapId,
}) : super(type: DrawingShapeType.rectangle);
/// Get all corner points for rendering
List<LatLng> get corners => [
topLeft,
LatLng(topLeft.latitude, bottomRight.longitude), // top right
bottomRight,
LatLng(bottomRight.latitude, topLeft.longitude), // bottom left
topLeft, // close the rectangle
];
topLeft,
LatLng(topLeft.latitude, bottomRight.longitude),
bottomRight,
LatLng(bottomRight.latitude, topLeft.longitude),
topLeft,
];
@override
Map<String, dynamic> toJson() {
@@ -328,27 +366,41 @@ class RectangleDrawing extends MapDrawing {
'color': color.toARGB32(),
'createdAt': createdAt.toIso8601String(),
'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude},
'bottomRight': {'lat': bottomRight.latitude, 'lon': bottomRight.longitude},
'bottomRight': {
'lat': bottomRight.latitude,
'lon': bottomRight.longitude,
},
'isShared': isShared,
// Note: isHidden is not persisted - it's session-only
'coordinateSpace': coordinateSpace.name,
'mapId': normalizeCustomMapId(mapId),
};
}
@override
Map<String, dynamic> toNetworkJson() {
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2]
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
// Sender is fetched from packet metadata, not included in JSON
return {
final payload = <String, dynamic>{
't': type.index,
'c': DrawingColors.colorToIndex(color),
'b': [
double.parse(topLeft.latitude.toStringAsFixed(5)),
double.parse(topLeft.longitude.toStringAsFixed(5)),
double.parse(bottomRight.latitude.toStringAsFixed(5)),
double.parse(bottomRight.longitude.toStringAsFixed(5)),
],
};
if (coordinateSpace == MapCoordinateSpace.customMap) {
payload['m'] = normalizeCustomMapId(mapId);
payload['b'] = [
topLeft.latitude.round(),
topLeft.longitude.round(),
bottomRight.latitude.round(),
bottomRight.longitude.round(),
];
return payload;
}
payload['b'] = [
double.parse(topLeft.latitude.toStringAsFixed(5)),
double.parse(topLeft.longitude.toStringAsFixed(5)),
double.parse(bottomRight.latitude.toStringAsFixed(5)),
double.parse(bottomRight.longitude.toStringAsFixed(5)),
];
return payload;
}
static RectangleDrawing fromJson(Map<String, dynamic> json) {
@@ -360,11 +412,21 @@ class RectangleDrawing extends MapDrawing {
id: json['id'] as String,
color: Color(json['color'] as int),
createdAt: DateTime.parse(json['createdAt'] as String),
topLeft: LatLng(topLeftJson['lat'] as double, topLeftJson['lon'] as double),
bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double),
topLeft: LatLng(
(topLeftJson['lat'] as num).toDouble(),
(topLeftJson['lon'] as num).toDouble(),
),
bottomRight: LatLng(
(bottomRightJson['lat'] as num).toDouble(),
(bottomRightJson['lon'] as num).toDouble(),
),
senderName: senderName,
isReceived: senderName != null, // Mark as received if sender is present
isReceived: senderName != null,
isShared: json['isShared'] as bool? ?? false,
coordinateSpace: MapCoordinateSpace.fromName(
json['coordinateSpace'] as String?,
),
mapId: normalizeCustomMapId(json['mapId'] as String?),
);
}
@@ -372,27 +434,35 @@ class RectangleDrawing extends MapDrawing {
Map<String, dynamic> json, {
String? senderName,
String? messageId,
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
String? mapId,
}) {
// Parse ultra-compact format
final bounds = (json['b'] as List<dynamic>).cast<double>();
final bounds = (json['b'] as List<dynamic>).cast<num>();
return RectangleDrawing(
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
id: DateTime.now().millisecondsSinceEpoch.toString(),
color: DrawingColors.indexToColor(json['c'] as int),
createdAt: DateTime.now(),
topLeft: LatLng(bounds[0], bounds[1]),
bottomRight: LatLng(bounds[2], bounds[3]),
topLeft: LatLng(bounds[0].toDouble(), bounds[1].toDouble()),
bottomRight: LatLng(bounds[2].toDouble(), bounds[3].toDouble()),
senderName: senderName,
isReceived: true,
messageId: messageId, // Link to source message
isShared: false, // Received drawings are not marked as shared
messageId: messageId,
coordinateSpace: coordinateSpace,
mapId: normalizeCustomMapId(mapId),
);
}
/// Create a copy with updated corners
RectangleDrawing copyWith({
LatLng? topLeft,
LatLng? bottomRight,
bool? isHidden,
bool? isShared,
bool? isReceived,
String? messageId,
String? senderName,
MapCoordinateSpace? coordinateSpace,
String? mapId,
}) {
return RectangleDrawing(
id: id,
@@ -400,12 +470,18 @@ class RectangleDrawing extends MapDrawing {
createdAt: createdAt,
topLeft: topLeft ?? this.topLeft,
bottomRight: bottomRight ?? this.bottomRight,
isHidden: isHidden ?? this.isHidden,
isShared: isShared ?? this.isShared,
isReceived: isReceived ?? this.isReceived,
messageId: messageId ?? this.messageId,
senderName: senderName ?? this.senderName,
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
mapId: mapId ?? this.mapId,
);
}
@override
LatLng getCenter() {
// Center is the midpoint between top-left and bottom-right
return LatLng(
(topLeft.latitude + bottomRight.latitude) / 2,
(topLeft.longitude + bottomRight.longitude) / 2,
@@ -414,7 +490,6 @@ class RectangleDrawing extends MapDrawing {
@override
LatLngBounds getBounds() {
// Bounds are simply the two corners
return LatLngBounds(topLeft, bottomRight);
}
}

View File

@@ -9,6 +9,7 @@ export 'package:meshcore_client/meshcore_client.dart'
import 'package:flutter/foundation.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'sar_marker.dart';
import 'map_coordinate_space.dart';
import '../utils/voice_message_parser.dart';
extension MessageVoiceExtension on Message {
@@ -47,7 +48,7 @@ extension MessageSarExtension on Message {
/// Convert to a [SarMarker] if this message contains SAR data.
SarMarker? toSarMarker() {
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
if (!isSarMarker || sarMarkerType == null) {
return null;
}
@@ -57,16 +58,26 @@ extension MessageSarExtension on Message {
debugPrint(' message.sarMarkerType: $sarMarkerType');
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
final coordinateSpace = sarCustomMapPoint != null && sarCustomMapId != null
? MapCoordinateSpace.customMap
: MapCoordinateSpace.geo;
final location = sarCustomMapPoint ?? sarGpsCoordinates;
if (location == null) {
return null;
}
return SarMarker(
id: id,
type: sarMarkerType!,
location: sarGpsCoordinates!,
location: location,
timestamp: sentAt,
senderPublicKey: senderPublicKeyPrefix,
senderName: senderName,
notes: sarNotes,
customEmoji: sarCustomEmoji,
colorIndex: sarColorIndex,
coordinateSpace: coordinateSpace,
mapId: sarCustomMapId,
);
}
}

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

@@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../l10n/app_localizations.dart';
import 'map_coordinate_space.dart';
import '../services/sar_template_service.dart';
/// SAR (Search & Rescue) marker types
@@ -78,6 +79,8 @@ class SarMarker {
final String? notes;
final String? customEmoji; // For custom SAR markers not in predefined types
final int? colorIndex; // Color index (0-7) from standard palette
final MapCoordinateSpace coordinateSpace;
final String? mapId;
SarMarker({
required this.id,
@@ -89,8 +92,13 @@ class SarMarker {
this.notes,
this.customEmoji,
this.colorIndex,
this.coordinateSpace = MapCoordinateSpace.geo,
this.mapId,
});
bool get isCustomMapMarker =>
coordinateSpace == MapCoordinateSpace.customMap && mapId != null;
/// Get sender public key as hex string (short)
String? get senderKeyShort {
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
@@ -167,6 +175,8 @@ class SarMarker {
String? notes,
String? customEmoji,
int? colorIndex,
MapCoordinateSpace? coordinateSpace,
String? mapId,
}) {
return SarMarker(
id: id ?? this.id,
@@ -178,6 +188,8 @@ class SarMarker {
notes: notes ?? this.notes,
customEmoji: customEmoji ?? this.customEmoji,
colorIndex: colorIndex ?? this.colorIndex,
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
mapId: mapId ?? this.mapId,
);
}

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
@@ -7,19 +8,46 @@ import '../models/message_contact_location.dart';
import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart';
import '../utils/fast_gps_packet.dart';
import '../utils/rssi_location_estimator.dart';
import '../utils/key_comparison.dart';
class PendingAdvert {
final Uint8List publicKey;
final DateTime receivedAt;
final String? advName;
final int? typeValue;
final int? flags;
final int? lastAdvert;
final int? advLat;
final int? advLon;
final int? signedEncodedPathLen;
final Uint8List? paddedPathBytes;
final int? rxRssiDbm;
final int? rxSnrRaw;
final int? repeaterBatteryMv;
final int? repeaterQueueLen;
final int? repeaterLastRssi;
final int? repeaterLastSnrRaw;
final int? repeaterUptimeSecs;
const PendingAdvert({
required this.publicKey,
required this.receivedAt,
this.advName,
this.typeValue,
this.flags,
this.lastAdvert,
this.advLat,
this.advLon,
this.signedEncodedPathLen,
this.paddedPathBytes,
this.rxRssiDbm,
this.rxSnrRaw,
this.repeaterBatteryMv,
this.repeaterQueueLen,
this.repeaterLastRssi,
this.repeaterLastSnrRaw,
this.repeaterUptimeSecs,
});
String get publicKeyHex =>
@@ -33,16 +61,55 @@ class PendingAdvert {
PendingAdvert copyWith({
Uint8List? publicKey,
DateTime? receivedAt,
String? advName,
int? typeValue,
int? flags,
int? lastAdvert,
int? advLat,
int? advLon,
int? signedEncodedPathLen,
Uint8List? paddedPathBytes,
int? rxRssiDbm,
int? rxSnrRaw,
int? repeaterBatteryMv,
int? repeaterQueueLen,
int? repeaterLastRssi,
int? repeaterLastSnrRaw,
int? repeaterUptimeSecs,
}) {
return PendingAdvert(
publicKey: publicKey ?? this.publicKey,
receivedAt: receivedAt ?? this.receivedAt,
advName: advName ?? this.advName,
typeValue: typeValue ?? this.typeValue,
flags: flags ?? this.flags,
lastAdvert: lastAdvert ?? this.lastAdvert,
advLat: advLat ?? this.advLat,
advLon: advLon ?? this.advLon,
signedEncodedPathLen: signedEncodedPathLen ?? this.signedEncodedPathLen,
paddedPathBytes: paddedPathBytes ?? this.paddedPathBytes,
rxRssiDbm: rxRssiDbm ?? this.rxRssiDbm,
rxSnrRaw: rxSnrRaw ?? this.rxSnrRaw,
repeaterBatteryMv: repeaterBatteryMv ?? this.repeaterBatteryMv,
repeaterQueueLen: repeaterQueueLen ?? this.repeaterQueueLen,
repeaterLastRssi: repeaterLastRssi ?? this.repeaterLastRssi,
repeaterLastSnrRaw: repeaterLastSnrRaw ?? this.repeaterLastSnrRaw,
repeaterUptimeSecs: repeaterUptimeSecs ?? this.repeaterUptimeSecs,
);
}
double? get repeaterBatteryPercent {
if (repeaterBatteryMv == null) return null;
final voltage = repeaterBatteryMv! / 1000.0;
if (voltage <= 3.0) return 0.0;
if (voltage >= 4.2) return 100.0;
return ((voltage - 3.0) / 1.2) * 100.0;
}
double? get repeaterLastSnr =>
repeaterLastSnrRaw == null ? null : repeaterLastSnrRaw! / 4.0;
double? get rxSnr => rxSnrRaw == null ? null : rxSnrRaw! / 4.0;
}
class _RetainedRoute {
@@ -62,8 +129,15 @@ class ContactsProvider with ChangeNotifier {
final Map<String, Contact> _contacts = {};
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
final Map<String, PendingAdvert> _pendingAdverts = {};
final Map<String, LatLng> _estimatedLocations = {};
final Map<String, List<RssiObservation>> _rssiObservations = {};
final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false;
bool _isPersisting = false;
bool _persistRequested = false;
bool _isPersistingPendingAdverts = false;
bool _persistPendingAdvertsRequested = false;
String? _storageNamespace;
// Add default public channel on initialization
ContactsProvider() {
@@ -71,18 +145,74 @@ class ContactsProvider with ChangeNotifier {
}
bool get isInitialized => _isInitialized;
String? get storageNamespace => _storageNamespace;
/// Initialize and load persisted contacts at app startup
/// This loads contacts without filtering, allowing offline viewing
Future<void> initializeEarly() async {
if (_isInitialized) return;
await _loadFromStorage();
}
Future<void> reloadFromStorage({
String? namespace,
Uint8List? devicePublicKey,
}) async {
_storageNamespace = namespace;
await _loadFromStorage(force: true, devicePublicKey: devicePublicKey);
}
Future<void> persistNow() async {
await _storageService.saveContacts(
_contactsForStorage(),
namespace: _storageNamespace,
);
await _storageService.saveContactGroups(
_savedContactGroups,
namespace: _storageNamespace,
);
await _storageService.savePendingAdverts(
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
namespace: _storageNamespace,
);
}
Future<void> cloneCurrentStorageTo(String? namespace) async {
await _storageService.saveContacts(
_contactsForStorage(),
namespace: namespace,
);
await _storageService.saveContactGroups(
_savedContactGroups,
namespace: namespace,
);
await _storageService.savePendingAdverts(
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
namespace: namespace,
);
}
Future<void> _loadFromStorage({
bool force = false,
Uint8List? devicePublicKey,
}) async {
if (_isInitialized && !force) return;
try {
_resetInMemoryState();
debugPrint(
'📦 [ContactsProvider] Early loading persisted contacts (no filtering)...',
);
final storedContacts = await _storageService.loadContacts();
final storedGroups = await _storageService.loadContactGroups();
final storedContacts = await _storageService.loadContacts(
namespace: _storageNamespace,
);
final storedGroups = await _storageService.loadContactGroups(
namespace: _storageNamespace,
);
final storedPendingAdverts = await _storageService.loadPendingAdverts(
namespace: _storageNamespace,
);
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
@@ -99,6 +229,10 @@ class ContactsProvider with ChangeNotifier {
_savedContactGroups
..clear()
..addAll(storedGroups);
_restorePendingAdverts(
storedPendingAdverts,
devicePublicKey: devicePublicKey,
);
debugPrint(
'✅ [ContactsProvider] Early loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
);
@@ -125,40 +259,9 @@ class ContactsProvider with ChangeNotifier {
return;
}
try {
debugPrint('📦 [ContactsProvider] Loading persisted contacts...');
final storedContacts = await _storageService.loadContacts(
excludePublicKey: devicePublicKey,
);
final storedGroups = await _storageService.loadContactGroups();
// Add stored contacts (excluding any with all-zeros public key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
for (final contact in storedContacts) {
// Skip any contacts with all-zeros public key (shouldn't happen, but safety check)
if (contact.publicKeyHex == publicChannelKey) {
continue;
}
_contacts[contact.publicKeyHex] = contact;
}
_isInitialized = true;
_savedContactGroups
..clear()
..addAll(storedGroups);
debugPrint(
'✅ [ContactsProvider] Loaded ${storedContacts.length} persisted contacts and ${storedGroups.length} groups',
);
// Ensure public channel exists after loading
_ensurePublicChannelExists();
notifyListeners();
} catch (e) {
debugPrint('❌ [ContactsProvider] Error initializing: $e');
_isInitialized = true; // Mark as initialized even on error
_ensurePublicChannelExists();
await _loadFromStorage(force: true, devicePublicKey: devicePublicKey);
if (devicePublicKey != null) {
_removeSelfContact(devicePublicKey);
}
}
@@ -202,23 +305,48 @@ class ContactsProvider with ChangeNotifier {
}
}
/// Persist contacts to storage (async, non-blocking)
/// Persist contacts to storage (async, non-blocking, coalescing).
Future<void> _persistContacts() async {
_persistRequested = true;
if (_isPersisting) return;
_isPersisting = true;
try {
// Don't persist the public channel pseudo-contact (all zeros key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
final contactsToSave = _contacts.entries
.where((entry) => entry.key != publicChannelKey)
.map((entry) => entry.value)
.toList();
await _storageService.saveContacts(contactsToSave);
while (_persistRequested) {
_persistRequested = false;
await _storageService.saveContacts(
_contactsForStorage(),
namespace: _storageNamespace,
);
}
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contacts: $e');
} finally {
_isPersisting = false;
}
}
Future<void> _persistPendingAdverts() async {
_persistPendingAdvertsRequested = true;
if (_isPersistingPendingAdverts) return;
_isPersistingPendingAdverts = true;
try {
while (_persistPendingAdvertsRequested) {
_persistPendingAdvertsRequested = false;
await _storageService.savePendingAdverts(
_pendingAdverts.values.map(_pendingAdvertToJson).toList(),
namespace: _storageNamespace,
);
}
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting pending adverts: $e');
} finally {
_isPersistingPendingAdverts = false;
}
}
List<Contact> get contacts => _contacts.values.toList();
List<Contact> get favouriteContacts =>
_contacts.values.where((c) => c.isFavourite).toList();
List<SavedContactGroup> get savedContactGroups =>
List<SavedContactGroup>.from(_savedContactGroups)
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
@@ -226,6 +354,28 @@ class ContactsProvider with ChangeNotifier {
_pendingAdverts.values.toList()
..sort((a, b) => b.receivedAt.compareTo(a.receivedAt));
PendingAdvert? pendingAdvertByKey(Uint8List publicKey) {
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
return _pendingAdverts[keyHex];
}
bool shouldEnrichPendingAdvert(Uint8List publicKey) {
final advert = pendingAdvertByKey(publicKey);
if (advert == null) {
return false;
}
final hasName = advert.advName?.trim().isNotEmpty ?? false;
final hasType = advert.typeValue != null && advert.typeValue != 0;
final hasLocation =
advert.advLat != null &&
advert.advLon != null &&
(advert.advLat != 0 || advert.advLon != 0);
return !(hasName && hasType && hasLocation);
}
List<SavedContactGroup> savedGroupsForSection(String sectionKey) {
return savedContactGroups
.where((group) => group.sectionKey == sectionKey)
@@ -318,7 +468,10 @@ class ContactsProvider with ChangeNotifier {
Future<void> _persistSavedGroups() async {
try {
await _storageService.saveContactGroups(_savedContactGroups);
await _storageService.saveContactGroups(
_savedContactGroups,
namespace: _storageNamespace,
);
} catch (e) {
debugPrint('❌ [ContactsProvider] Error persisting contact groups: $e');
}
@@ -332,6 +485,9 @@ class ContactsProvider with ChangeNotifier {
List<Contact> get repeaters =>
contacts.where((c) => c.isRepeater).toList()..sort(_sortByLastSeen);
List<Contact> get sensorContacts =>
contacts.where((c) => c.isSensor).toList()..sort(_sortByLastSeen);
List<Contact> get rooms =>
contacts.where((c) => c.isRoom).toList()..sort(_sortByLastSeen);
@@ -348,13 +504,71 @@ class ContactsProvider with ChangeNotifier {
..sort(_sortByLastSeen);
}
/// All estimated locations (for passing to map marker service).
Map<String, LatLng> get estimatedLocations =>
Map.unmodifiable(_estimatedLocations);
/// Get estimated location for a contact (RSSI-based, near last-hop repeater).
LatLng? estimatedLocationFor(String publicKeyHex) =>
_estimatedLocations[publicKeyHex];
/// Set an estimated location for a contact that has no GPS.
void setEstimatedLocation(String publicKeyHex, LatLng location) {
_estimatedLocations[publicKeyHex] = location;
notifyListeners();
}
/// Record an RSSI observation and re-trilaterate the contact's position.
///
/// Keeps up to 5 observations per unique repeater (latest wins).
/// With multiple repeaters, trilateration produces a better estimate.
void addRssiObservation({
required String contactPublicKeyHex,
required List<int> contactPublicKey,
required RssiObservation observation,
}) {
final observations = _rssiObservations.putIfAbsent(
contactPublicKeyHex,
() => [],
);
// Replace existing observation from the same repeater, or add new
final repeaterKey =
'${observation.repeaterLocation.latitude},${observation.repeaterLocation.longitude}';
observations.removeWhere((o) =>
'${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' ==
repeaterKey);
observations.add(observation);
// Keep at most 8 observations (most recent per repeater)
if (observations.length > 8) {
observations.sort((a, b) => b.observedAt.compareTo(a.observedAt));
observations.removeRange(8, observations.length);
}
final estimated = RssiLocationEstimator.trilaterate(
observations: observations,
contactPublicKey: contactPublicKey,
);
if (estimated != null) {
_estimatedLocations[contactPublicKeyHex] = estimated;
notifyListeners();
}
}
/// Effective location: own GPS/advert > estimated from RSSI.
LatLng? effectiveLocationFor(Contact contact) {
return contact.displayLocation ?? _estimatedLocations[contact.publicKeyHex];
}
/// Get contacts with location (for map display)
/// Includes contacts with estimated locations from RSSI.
List<Contact> get contactsWithLocation =>
contacts.where((c) => c.displayLocation != null).toList();
contacts.where((c) => effectiveLocationFor(c) != null).toList();
/// Get chat contacts with location (team members on map)
List<Contact> get chatContactsWithLocation =>
chatContacts.where((c) => c.displayLocation != null).toList();
chatContacts.where((c) => effectiveLocationFor(c) != null).toList();
MessageContactLocation? buildMessageContactLocationSnapshot(
Contact contact, {
@@ -435,11 +649,17 @@ class ContactsProvider with ChangeNotifier {
);
_contacts[contact.publicKeyHex] = updatedContact;
_pendingAdverts.remove(contact.publicKeyHex);
// Keep repeaters and sensors in pending adverts so they remain visible
// in the discovery list (with a checkmark). Remove others.
if (contact.type != ContactType.repeater &&
contact.type != ContactType.sensor) {
_pendingAdverts.remove(contact.publicKeyHex);
}
debugPrint(
' ✅ Contact added/updated. Total contacts: ${_contacts.length}, channels: ${channels.length}',
);
_persistContacts();
_persistPendingAdverts();
notifyListeners();
debugPrint(' 🔔 notifyListeners() called');
}
@@ -463,7 +683,10 @@ class ContactsProvider with ChangeNotifier {
incomingContact: contact,
existingContact: existingContact,
);
_pendingAdverts.remove(contact.publicKeyHex);
if (contact.type != ContactType.repeater &&
contact.type != ContactType.sensor) {
_pendingAdverts.remove(contact.publicKeyHex);
}
}
if (excluded > 0) {
debugPrint(
@@ -471,6 +694,7 @@ class ContactsProvider with ChangeNotifier {
);
}
_persistContacts();
_persistPendingAdverts();
notifyListeners();
}
@@ -503,7 +727,6 @@ class ContactsProvider with ChangeNotifier {
if (existingContact == null) {
var newContact = incomingContact.copyWith(
isNew: true,
nameOverride: existingContact?.nameOverride,
telemetry: mergedTelemetry,
outPathLen:
retainedRoute?.signedEncodedPathLen ?? incomingContact.outPathLen,
@@ -703,6 +926,10 @@ class ContactsProvider with ChangeNotifier {
}
/// Update contact telemetry
// Dedup: track last telemetry data hash per contact to avoid processing
// the same payload twice (0x8B + 0x8C can both fire for the same data).
final Map<String, (int, DateTime)> _lastTelemetryHash = {};
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
debugPrint(
@@ -710,6 +937,21 @@ class ContactsProvider with ChangeNotifier {
);
debugPrint(' LPP data size: ${lppData.length} bytes');
// Deduplicate: same data for same contact within 2 seconds = skip
final prefixHex = publicKeyPrefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final dataHash = lppData.fold<int>(0, (h, b) => h * 31 + b);
final now = DateTime.now();
final last = _lastTelemetryHash[prefixHex];
if (last != null &&
last.$1 == dataHash &&
now.difference(last.$2).inSeconds < 2) {
debugPrint(' ⏭️ Duplicate telemetry payload, skipping');
return;
}
_lastTelemetryHash[prefixHex] = (dataHash, now);
// Find contact by public key prefix
final contact = _findContactByPrefix(publicKeyPrefix);
if (contact == null) {
@@ -753,10 +995,19 @@ class ContactsProvider with ChangeNotifier {
telemetry.gpsLocation,
)) {
debugPrint(
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
' ⚠️ Incoming telemetry GPS is invalid/missing; keeping last known contact location as fallback: $incomingGps',
);
}
telemetry = mergedTelemetry;
telemetry = ContactTelemetry(
gpsLocation: telemetry.gpsLocation,
batteryPercentage: mergedTelemetry.batteryPercentage,
batteryMilliVolts: mergedTelemetry.batteryMilliVolts,
temperature: mergedTelemetry.temperature,
timestamp: mergedTelemetry.timestamp,
humidity: mergedTelemetry.humidity,
pressure: mergedTelemetry.pressure,
extraSensorData: telemetry.extraSensorData,
);
}
// Update contact with new telemetry AND last seen time
@@ -871,10 +1122,10 @@ class ContactsProvider with ChangeNotifier {
// last known reading for any field that is omitted in the incoming update.
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
final mergedExtraSensorData = <String, dynamic>{
...?existingTelemetry?.extraSensorData,
...?incomingTelemetry.extraSensorData,
};
final mergedExtraSensorData = _mergeExtraSensorData(
existingTelemetry?.extraSensorData,
incomingTelemetry.extraSensorData,
);
return ContactTelemetry(
gpsLocation: incomingGps ?? previousGps,
@@ -895,6 +1146,49 @@ class ContactsProvider with ChangeNotifier {
);
}
Map<String, dynamic> _mergeExtraSensorData(
Map<String, dynamic>? existingExtraSensorData,
Map<String, dynamic>? incomingExtraSensorData,
) {
final merged = <String, dynamic>{...?existingExtraSensorData};
if (incomingExtraSensorData == null || incomingExtraSensorData.isEmpty) {
return merged;
}
final incomingMetricFamilies = incomingExtraSensorData.keys
.map(_telemetryMetricFamilyForKey)
.whereType<String>()
.toSet();
if (incomingMetricFamilies.isNotEmpty) {
merged.removeWhere((key, _) {
final family = _telemetryMetricFamilyForKey(key);
return family != null && incomingMetricFamilies.contains(family);
});
}
merged.addAll(incomingExtraSensorData);
return merged;
}
String? _telemetryMetricFamilyForKey(String key) {
const sourcePrefix = '__source_channel:';
if (key.startsWith(sourcePrefix)) {
return key.substring(sourcePrefix.length);
}
final separatorIndex = key.lastIndexOf('_');
if (separatorIndex <= 0 || separatorIndex == key.length - 1) {
return key;
}
final suffix = key.substring(separatorIndex + 1);
if (int.tryParse(suffix) == null) {
return key;
}
return key.substring(0, separatorIndex);
}
int _coordinateToAdvertMicrodegrees(double coordinate) {
return (coordinate * 1e6).round();
}
@@ -908,7 +1202,7 @@ class ContactsProvider with ChangeNotifier {
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
for (final contact in contacts) {
for (final contact in _contacts.values) {
if (contact.publicKeyHex.startsWith(prefixHex)) {
return contact;
}
@@ -1063,39 +1357,132 @@ class ContactsProvider with ChangeNotifier {
}
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
/// Excludes self key and existing contacts.
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
/// Excludes only self key; known contacts still keep a discovery entry.
bool addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return;
return false;
}
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
if (_contacts.containsKey(keyHex)) {
_pendingAdverts.remove(keyHex);
return;
}
final existing = _pendingAdverts[keyHex];
final now = DateTime.now();
if (existing != null) {
_pendingAdverts[keyHex] = existing.copyWith(receivedAt: now);
_persistPendingAdverts();
notifyListeners();
return false;
} else {
_pendingAdverts[keyHex] = PendingAdvert(
publicKey: Uint8List.fromList(publicKey),
receivedAt: now,
);
_persistPendingAdverts();
notifyListeners();
return true;
}
}
bool addOrUpdatePendingAdvertContact(
Contact contact, {
Uint8List? devicePublicKey,
}) {
if (devicePublicKey != null && contact.publicKey.matches(devicePublicKey)) {
return false;
}
final keyHex = contact.publicKeyHex;
final route = ContactRouteCodec.fromContact(contact);
final existing = _pendingAdverts[keyHex];
final now = DateTime.now();
final updated =
(existing ??
PendingAdvert(
publicKey: Uint8List.fromList(contact.publicKey),
receivedAt: now,
))
.copyWith(
receivedAt: now,
advName: contact.advName.trim().isEmpty ? null : contact.advName,
typeValue: contact.type.value,
flags: contact.flags,
lastAdvert: contact.lastAdvert,
advLat: contact.advLat,
advLon: contact.advLon,
signedEncodedPathLen:
route?.signedEncodedPathLen ?? existing?.signedEncodedPathLen,
paddedPathBytes: route?.paddedPathBytes == null
? existing?.paddedPathBytes
: Uint8List.fromList(route!.paddedPathBytes),
);
_pendingAdverts[keyHex] = updated;
_persistPendingAdverts();
notifyListeners();
return existing == null;
}
bool addOrUpdatePendingAdvertMetadata({
required Uint8List publicKey,
required int typeValue,
Uint8List? devicePublicKey,
int? flags,
String? advName,
int? lastAdvert,
int? advLat,
int? advLon,
int? signedEncodedPathLen,
Uint8List? paddedPathBytes,
int? rxRssiDbm,
int? rxSnrRaw,
DateTime? receivedAt,
}) {
if (devicePublicKey != null && publicKey.matches(devicePublicKey)) {
return false;
}
final keyHex = publicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final existing = _pendingAdverts[keyHex];
final nextReceivedAt = receivedAt ?? DateTime.now();
_pendingAdverts[keyHex] =
(existing ??
PendingAdvert(
publicKey: Uint8List.fromList(publicKey),
receivedAt: nextReceivedAt,
))
.copyWith(
receivedAt: nextReceivedAt,
typeValue: typeValue,
advName: advName?.trim().isNotEmpty == true
? advName!.trim()
: existing?.advName,
flags: flags ?? existing?.flags,
lastAdvert: lastAdvert ?? existing?.lastAdvert,
advLat: advLat ?? existing?.advLat,
advLon: advLon ?? existing?.advLon,
signedEncodedPathLen:
signedEncodedPathLen ?? existing?.signedEncodedPathLen,
paddedPathBytes: paddedPathBytes == null
? existing?.paddedPathBytes
: Uint8List.fromList(paddedPathBytes),
rxRssiDbm: rxRssiDbm ?? existing?.rxRssiDbm,
rxSnrRaw: rxSnrRaw ?? existing?.rxSnrRaw,
);
_persistPendingAdverts();
notifyListeners();
return existing == null;
}
/// Find contact by name
Contact? findContactByName(String name) {
return contacts.firstWhere(
(c) => c.advName == name,
orElse: () => contacts.first,
);
for (final contact in _contacts.values) {
if (contact.advName == name) return contact;
}
return null;
}
/// Get contacts with low battery
@@ -1144,7 +1531,9 @@ class ContactsProvider with ChangeNotifier {
void clearContacts() {
_contacts.clear();
_pendingAdverts.clear();
_ensurePublicChannelExists();
_persistContacts();
_persistPendingAdverts();
notifyListeners();
}
@@ -1167,12 +1556,148 @@ class ContactsProvider with ChangeNotifier {
_contacts.remove(publicKeyHex);
_pendingAdverts.remove(publicKeyHex);
_persistContacts();
_persistPendingAdverts();
notifyListeners();
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
return await _storageService.getStorageStats();
return await _storageService.getStorageStats(namespace: _storageNamespace);
}
Future<void> clearPendingAdverts() async {
if (_pendingAdverts.isEmpty) {
return;
}
_pendingAdverts.clear();
await _storageService.clearPendingAdverts(namespace: _storageNamespace);
notifyListeners();
}
void updatePendingAdvertStatusByPrefix(
Uint8List publicKeyPrefix, {
int? batteryMv,
int? queueLen,
int? lastRssi,
int? lastSnrRaw,
int? uptimeSecs,
}) {
final prefixHex = publicKeyPrefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final match = _pendingAdverts.entries.where(
(entry) => entry.key.startsWith(prefixHex),
);
if (match.isEmpty) {
return;
}
final key = match.first.key;
final existing = _pendingAdverts[key]!;
_pendingAdverts[key] = existing.copyWith(
repeaterBatteryMv: batteryMv,
repeaterQueueLen: queueLen,
repeaterLastRssi: lastRssi,
repeaterLastSnrRaw: lastSnrRaw,
repeaterUptimeSecs: uptimeSecs,
);
_persistPendingAdverts();
notifyListeners();
}
List<Contact> _contactsForStorage() {
// Don't persist the public channel pseudo-contact (all zeros key)
const publicChannelKey =
'0000000000000000000000000000000000000000000000000000000000000000';
return _contacts.entries
.where((entry) => entry.key != publicChannelKey)
.map((entry) => entry.value)
.toList();
}
void _restorePendingAdverts(
List<Map<String, dynamic>> storedPendingAdverts, {
Uint8List? devicePublicKey,
}) {
_pendingAdverts.clear();
for (final json in storedPendingAdverts) {
final advert = _pendingAdvertFromJson(json);
if (advert == null) continue;
if (devicePublicKey != null &&
advert.publicKey.matches(devicePublicKey)) {
continue;
}
if (_contacts.containsKey(advert.publicKeyHex)) {
continue;
}
_pendingAdverts[advert.publicKeyHex] = advert;
}
}
void _resetInMemoryState() {
_contacts.clear();
_savedContactGroups.clear();
_pendingAdverts.clear();
}
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {
return {
'publicKey': base64Encode(advert.publicKey),
'receivedAtMillis': advert.receivedAt.millisecondsSinceEpoch,
'advName': advert.advName,
'typeValue': advert.typeValue,
'flags': advert.flags,
'lastAdvert': advert.lastAdvert,
'advLat': advert.advLat,
'advLon': advert.advLon,
'signedEncodedPathLen': advert.signedEncodedPathLen,
'paddedPathBytes': advert.paddedPathBytes == null
? null
: base64Encode(advert.paddedPathBytes!),
'rxRssiDbm': advert.rxRssiDbm,
'rxSnrRaw': advert.rxSnrRaw,
'repeaterBatteryMv': advert.repeaterBatteryMv,
'repeaterQueueLen': advert.repeaterQueueLen,
'repeaterLastRssi': advert.repeaterLastRssi,
'repeaterLastSnrRaw': advert.repeaterLastSnrRaw,
'repeaterUptimeSecs': advert.repeaterUptimeSecs,
};
}
PendingAdvert? _pendingAdvertFromJson(Map<String, dynamic> json) {
try {
return PendingAdvert(
publicKey: Uint8List.fromList(
base64Decode(json['publicKey'] as String),
),
receivedAt: DateTime.fromMillisecondsSinceEpoch(
json['receivedAtMillis'] as int,
),
advName: json['advName'] as String?,
typeValue: json['typeValue'] as int?,
flags: json['flags'] as int?,
lastAdvert: json['lastAdvert'] as int?,
advLat: json['advLat'] as int?,
advLon: json['advLon'] as int?,
signedEncodedPathLen: json['signedEncodedPathLen'] as int?,
paddedPathBytes: json['paddedPathBytes'] == null
? null
: Uint8List.fromList(
base64Decode(json['paddedPathBytes'] as String),
),
rxRssiDbm: json['rxRssiDbm'] as int?,
rxSnrRaw: json['rxSnrRaw'] as int?,
repeaterBatteryMv: json['repeaterBatteryMv'] as int?,
repeaterQueueLen: json['repeaterQueueLen'] as int?,
repeaterLastRssi: json['repeaterLastRssi'] as int?,
repeaterLastSnrRaw: json['repeaterLastSnrRaw'] as int?,
repeaterUptimeSecs: json['repeaterUptimeSecs'] as int?,
);
} catch (e) {
debugPrint(
'❌ [ContactsProvider] Error parsing pending advert from JSON: $e',
);
return null;
}
}
/// Get contact count by type
@@ -1180,6 +1705,7 @@ class ContactsProvider with ChangeNotifier {
return {
'chat': chatContacts.length,
'repeater': repeaters.length,
'sensor': sensorContacts.length,
'room': rooms.length,
'total': contacts.length,
};

View File

@@ -1,8 +1,11 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/map_drawing.dart';
import '../models/map_coordinate_space.dart';
import '../services/profiles_feature_service.dart';
import '../utils/drawing_message_parser.dart';
/// Drawing mode state
@@ -22,6 +25,7 @@ class DrawingProvider with ChangeNotifier {
// Completed drawings
final List<MapDrawing> _drawings = [];
bool _isInitialized = false;
// In-progress drawing
MapDrawing? _currentDrawing;
@@ -32,12 +36,18 @@ class DrawingProvider with ChangeNotifier {
LatLng? _measurementPoint1;
LatLng? _measurementPoint2;
double? _measuredDistance; // in meters
MapCoordinateSpace _activeCoordinateSpace = MapCoordinateSpace.geo;
String? _activeMapId;
double? _activeMetersPerPixel;
// Getters
DrawingMode get drawingMode => _drawingMode;
Color get selectedColor => _selectedColor;
bool get showReceivedDrawings => _showReceivedDrawings;
bool get showSarMarkers => _showSarMarkers;
MapCoordinateSpace get activeCoordinateSpace => _activeCoordinateSpace;
String? get activeMapId => _activeMapId;
double? get activeMetersPerPixel => _activeMetersPerPixel;
List<MapDrawing> get drawings {
// Filter out hidden drawings first
var visibleDrawings = _drawings.where((d) => !d.isHidden);
@@ -47,12 +57,24 @@ class DrawingProvider with ChangeNotifier {
visibleDrawings = visibleDrawings.where((d) => !d.isReceived);
}
visibleDrawings = visibleDrawings.where((drawing) {
if (drawing.coordinateSpace != _activeCoordinateSpace) {
return false;
}
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
return drawing.mapId != null && drawing.mapId == _activeMapId;
}
return true;
});
return List.unmodifiable(visibleDrawings.toList());
}
MapDrawing? get currentDrawing => _currentDrawing;
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;
@@ -61,6 +83,36 @@ class DrawingProvider with ChangeNotifier {
Future<void> initialize() async {
await _loadPreferences();
await _loadDrawings();
_isInitialized = true;
}
Future<void> reloadProfileScopedState() async {
await _loadPreferences();
await _loadDrawings();
notifyListeners();
}
void setMapContext({
required MapCoordinateSpace coordinateSpace,
String? mapId,
double? metersPerPixel,
}) {
final changed =
coordinateSpace != _activeCoordinateSpace ||
mapId != _activeMapId ||
metersPerPixel != _activeMetersPerPixel;
_activeCoordinateSpace = coordinateSpace;
_activeMapId = mapId;
_activeMetersPerPixel = metersPerPixel;
if (_measurementPoint1 != null && _measurementPoint2 != null) {
_measuredDistance = _calculateDistance(
_measurementPoint1!,
_measurementPoint2!,
);
}
if (changed) {
notifyListeners();
}
}
/// Set drawing mode
@@ -95,15 +147,19 @@ class DrawingProvider with ChangeNotifier {
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
_showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true;
_showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true;
_showReceivedDrawings =
prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true;
_showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true;
notifyListeners();
}
Future<void> _savePreferences() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings);
await prefs.setBool(_showSarMarkersKey, _showSarMarkers);
await prefs.setBool(
_scopedKey(_showReceivedDrawingsKey),
_showReceivedDrawings,
);
await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers);
}
/// Start drawing a line
@@ -134,6 +190,10 @@ class DrawingProvider with ChangeNotifier {
color: _selectedColor,
createdAt: DateTime.now(),
points: List.from(_currentLinePoints),
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
);
_drawings.add(drawing);
@@ -177,6 +237,10 @@ class DrawingProvider with ChangeNotifier {
? _rectangleStartPoint!.longitude
: endPoint.longitude,
),
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
);
notifyListeners();
}
@@ -213,6 +277,10 @@ class DrawingProvider with ChangeNotifier {
createdAt: DateTime.now(),
topLeft: topLeft,
bottomRight: bottomRight,
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
);
_drawings.add(drawing);
@@ -234,7 +302,9 @@ class DrawingProvider with ChangeNotifier {
/// Set second measurement point and calculate distance
void setMeasurementPoint2(LatLng point) {
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) return;
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) {
return;
}
_measurementPoint2 = point;
_measuredDistance = _calculateDistance(_measurementPoint1!, point);
@@ -243,6 +313,13 @@ class DrawingProvider with ChangeNotifier {
/// Calculate distance between two points using Haversine formula
double _calculateDistance(LatLng point1, LatLng point2) {
if (_activeCoordinateSpace == MapCoordinateSpace.customMap &&
_activeMetersPerPixel != null) {
final dy = point2.latitude - point1.latitude;
final dx = point2.longitude - point1.longitude;
final pixelDistance = math.sqrt((dx * dx) + (dy * dy));
return pixelDistance * _activeMetersPerPixel!;
}
const Distance distance = Distance();
return distance.as(LengthUnit.Meter, point1, point2);
}
@@ -299,7 +376,7 @@ class DrawingProvider with ChangeNotifier {
final prefs = await SharedPreferences.getInstance();
final jsonList = _drawings.map((d) => d.toJson()).toList();
final jsonString = jsonEncode(jsonList);
await prefs.setString(_storageKey, jsonString);
await prefs.setString(_scopedKey(_storageKey), jsonString);
} catch (e) {
debugPrint('Error saving drawings: $e');
}
@@ -309,8 +386,11 @@ class DrawingProvider with ChangeNotifier {
Future<void> _loadDrawings() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_storageKey);
if (jsonString == null) return;
final jsonString = prefs.getString(_scopedKey(_storageKey));
if (jsonString == null || jsonString.isEmpty) {
_drawings.clear();
return;
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
_drawings.clear();
@@ -336,6 +416,10 @@ class DrawingProvider with ChangeNotifier {
color: _selectedColor,
createdAt: DateTime.now(),
points: _currentLinePoints,
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
);
} else if (_drawingMode == DrawingMode.rectangle &&
_currentDrawing != null) {
@@ -373,6 +457,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
} else if (drawing is RectangleDrawing) {
return RectangleDrawing(
@@ -387,6 +473,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
}
return drawing;
@@ -401,9 +489,27 @@ class DrawingProvider with ChangeNotifier {
}
}
List<Map<String, dynamic>> exportDrawingsJson() {
return _drawings.map((drawing) => drawing.toJson()).toList();
}
Future<void> replaceDrawingsFromJson(
List<Map<String, dynamic>> jsonList,
) async {
_drawings
..clear()
..addAll(jsonList.map(MapDrawing.fromJson).whereType<MapDrawing>());
await _saveDrawings();
notifyListeners();
}
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
/// Get all unshared drawings (local drawings not yet sent)
List<MapDrawing> getUnsharedDrawings() {
return _drawings.where((d) => !d.isShared && !d.isReceived).toList();
return drawings.where((d) => !d.isShared && !d.isReceived).toList();
}
/// Mark a drawing as shared
@@ -425,6 +531,8 @@ class DrawingProvider with ChangeNotifier {
isShared: true,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
} else if (drawing is RectangleDrawing) {
_drawings[index] = RectangleDrawing(
@@ -439,6 +547,8 @@ class DrawingProvider with ChangeNotifier {
isShared: true,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
}
@@ -466,6 +576,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: !drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
} else if (drawing is RectangleDrawing) {
_drawings[index] = RectangleDrawing(
@@ -480,6 +592,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: !drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
}

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,4 +1,5 @@
import 'dart:convert';
import 'dart:math' as math;
import '../../models/message.dart';
import '../../models/contact.dart';
@@ -19,10 +20,19 @@ class MessageRetryManager {
final Map<String, DateTime> _lastRetryTimes = {};
final Map<String, int> _pathFailureStreaks = {};
static const int maxRetryAttempts = 4;
/// Max retry attempts when the contact has a known path.
/// Official MeshCore app uses 5 (with auto-retry) or 3 (without).
static const int maxRetryAttemptsWithPath = 5;
/// No retries for flood-only contacts (no known path).
/// Value 0 means: don't retry at all, go straight to fallback/fail.
static const int maxRetryAttemptsFloodOnly = 0;
@Deprecated('Use maxRetryAttemptsForContact instead')
static const int maxRetryAttempts = maxRetryAttemptsWithPath;
// Retry backoff values in milliseconds.
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
static const List<int> _retryDelays = [1000, 2000, 4000, 8000, 8000];
static const int _defaultLoRaSf = 10;
static const int _defaultLoRaCr = 5;
static const int _defaultLoRaBwHz = 250000;
@@ -38,32 +48,57 @@ class MessageRetryManager {
return _retryDelays[attempt];
}
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
/// provide one or returns an invalid value.
static final math.Random _rng = math.Random();
/// Calculate a delivery-ACK timeout with random jitter.
///
/// Matches the official MeshCore app: `suggestedTimeout + random(1-8s)`.
/// The jitter prevents collision when multiple messages are in flight.
int calculateAckTimeoutMs({
required String text,
required Contact? contact,
int? suggestedTimeoutMs,
}) {
int baseTimeout;
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
return suggestedTimeoutMs;
baseTimeout = suggestedTimeoutMs;
} else {
final payloadBytes = utf8.encode(text).length;
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
final hopCount = contact?.routeHasPath == true
? contact!.routeHopCount
: -1;
if (hopCount < 0) {
baseTimeout = ((airtimeMs * 10) + 4000).clamp(10000, 30000);
} else {
baseTimeout = ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
}
}
final payloadBytes = utf8.encode(text).length;
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
final hopCount = contact?.routeHasPath == true
? contact!.routeHopCount
: -1;
// Add random jitter: 500-2000ms to avoid collision
final jitterMs = 500 + _rng.nextInt(1501);
return baseTimeout + jitterMs;
}
if (hopCount < 0) {
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
}
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
/// Max attempts for a given contact based on whether it has a known path.
static int maxRetryAttemptsForContact(Contact? contact) {
final hasPath = contact?.routeHasPath ?? false;
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
}
bool canRetry(Message message, Contact contact) {
return message.retryAttempt < maxRetryAttempts;
return message.retryAttempt < maxRetryAttemptsForContact(contact);
}
/// Whether this is the last retry attempt.
/// When true, the caller should reset the path to force flood mode.
///
/// Note: called AFTER retryAttempt has been incremented, so we compare
/// directly against maxAttempts (not +1).
bool isLastAttempt(Message message, Contact contact) {
final maxAttempts = maxRetryAttemptsForContact(contact);
return maxAttempts > 1 && message.retryAttempt >= maxAttempts;
}
/// Track a retry attempt for a message

View File

@@ -1,30 +1,48 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:image_picker/image_picker.dart';
import 'package:latlong2/latlong.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/custom_map_config.dart';
import '../models/location_trail.dart';
import '../models/map_coordinate_space.dart';
import '../models/map_drawing.dart';
import '../models/sar_marker.dart';
import '../services/profiles_feature_service.dart';
import '../utils/custom_map_id.dart';
class MapProvider with ChangeNotifier {
static const String _customMapConfigKey = 'custom_map_config_v1';
static const String _customMapModeKey = 'custom_map_mode_v1';
static const String _customMapsDirName = 'custom_maps';
MapProvider() {
unawaited(_loadInitialState());
}
final ImagePicker _imagePicker = ImagePicker();
LatLng? _targetLocation;
LatLngBounds? _targetBounds;
double? _targetZoom;
bool _shouldAnimate = false;
MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo;
String? _targetMapId;
// Track which contact paths are currently visible
final Set<String> _visibleContactPaths = {};
// Location trail tracking
LocationTrail? _currentTrail;
bool _isTrailVisible = true;
final List<LocationTrail> _trailHistory = [];
// WMS overlay toggles
bool _showCadastralOverlay = false;
bool _showForestRoadsOverlay = false;
bool _showHikingTrailsOverlay = false;
@@ -37,29 +55,30 @@ class MapProvider with ChangeNotifier {
bool _showPlaceNamesOverlay = false;
bool _showMunicipalityBordersOverlay = false;
// Contact trail toggles
bool _showAllContactTrails = true; // Default to showing all contact trails
bool _showAllContactTrails = true;
bool _hideRepeatersOnMap = false;
// Imported trail (from GPX)
LocationTrail? _importedTrail;
// Download area selection
bool _isSelectingDownloadArea = false;
LatLngBounds? _downloadAreaBounds;
CustomMapConfig? _customMapConfig;
bool _isUsingCustomMap = false;
LatLng? get targetLocation => _targetLocation;
LatLngBounds? get targetBounds => _targetBounds;
double? get targetZoom => _targetZoom;
bool get shouldAnimate => _shouldAnimate;
MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace;
String? get targetMapId => _targetMapId;
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
// Trail getters
LocationTrail? get currentTrail => _currentTrail;
bool get isTrailVisible => _isTrailVisible;
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
bool get isTrailActive => _currentTrail?.isActive ?? false;
// WMS overlay getters
bool get showCadastralOverlay => _showCadastralOverlay;
bool get showForestRoadsOverlay => _showForestRoadsOverlay;
bool get showHikingTrailsOverlay => _showHikingTrailsOverlay;
@@ -72,69 +91,139 @@ class MapProvider with ChangeNotifier {
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
// Contact trail getters
bool get showAllContactTrails => _showAllContactTrails;
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
// Imported trail getters
LocationTrail? get importedTrail => _importedTrail;
// Download area getters
bool get isSelectingDownloadArea => _isSelectingDownloadArea;
LatLngBounds? get downloadAreaBounds => _downloadAreaBounds;
CustomMapConfig? get customMapConfig => _customMapConfig;
bool get hasCustomMap => _customMapConfig != null;
bool get isUsingCustomMap => _isUsingCustomMap && _customMapConfig != null;
bool get shouldHideGpsData => isUsingCustomMap;
LatLngBounds? get customMapBounds => _customMapConfig?.bounds;
Future<void> reloadProfileScopedState() async {
await _loadInitialState();
}
bool matchesActiveCustomMap(String? mapId) {
return hasCustomMap &&
normalizeCustomMapId(_customMapConfig!.mapId) ==
normalizeCustomMapId(mapId);
}
void navigateToLocation({
required LatLng location,
double zoom = 15.0,
bool animate = true,
}) {
if (_isUsingCustomMap) {
_isUsingCustomMap = false;
unawaited(_saveCustomMapState());
}
_targetLocation = location;
_targetBounds = null;
_targetZoom = zoom;
_shouldAnimate = animate;
_targetCoordinateSpace = MapCoordinateSpace.geo;
_targetMapId = null;
notifyListeners();
}
String? navigateToMapPoint({
required LatLng point,
required MapCoordinateSpace coordinateSpace,
String? mapId,
double zoom = 15.0,
bool animate = true,
}) {
if (coordinateSpace == MapCoordinateSpace.customMap) {
if (!matchesActiveCustomMap(mapId)) {
return 'Load the matching custom map to view this item.';
}
if (!_isUsingCustomMap) {
_isUsingCustomMap = true;
unawaited(_saveCustomMapState());
}
} else if (_isUsingCustomMap) {
_isUsingCustomMap = false;
unawaited(_saveCustomMapState());
}
_targetLocation = point;
_targetBounds = null;
_targetZoom = zoom;
_shouldAnimate = animate;
_targetCoordinateSpace = coordinateSpace;
_targetMapId = mapId;
notifyListeners();
return null;
}
String? navigateToBounds({
required LatLngBounds bounds,
required MapCoordinateSpace coordinateSpace,
String? mapId,
bool animate = true,
}) {
if (coordinateSpace == MapCoordinateSpace.customMap) {
if (!matchesActiveCustomMap(mapId)) {
return 'Load the matching custom map to view this item.';
}
if (!_isUsingCustomMap) {
_isUsingCustomMap = true;
unawaited(_saveCustomMapState());
}
} else if (_isUsingCustomMap) {
_isUsingCustomMap = false;
unawaited(_saveCustomMapState());
}
_targetBounds = bounds;
_targetLocation = bounds.center;
_targetZoom = null;
_shouldAnimate = animate;
_targetCoordinateSpace = coordinateSpace;
_targetMapId = mapId;
notifyListeners();
return null;
}
void clearNavigation() {
_targetLocation = null;
_targetBounds = null;
_targetZoom = null;
_shouldAnimate = false;
// Don't notify listeners to avoid rebuilds
_targetCoordinateSpace = MapCoordinateSpace.geo;
_targetMapId = null;
}
/// Navigate to a drawing by its ID
void navigateToDrawing(String drawingId, dynamic drawingProvider) {
debugPrint('🗺️ [MapProvider] navigateToDrawing called with ID: $drawingId');
// Find the drawing in the provider
final drawings = drawingProvider.drawings as List;
debugPrint('🗺️ [MapProvider] Total drawings in provider: ${drawings.length}');
final drawing = drawings.cast<dynamic>().firstWhere(
(d) => d.id == drawingId,
orElse: () => null,
);
String? navigateToDrawing(String drawingId, dynamic drawingProvider) {
final drawing = drawingProvider.getDrawingById(drawingId) as MapDrawing?;
if (drawing == null) {
debugPrint('⚠️ [MapProvider] Drawing $drawingId not found');
debugPrint('⚠️ [MapProvider] Available drawing IDs: ${drawings.map((d) => d.id).toList()}');
return;
return 'Drawing not found.';
}
// Use MapDrawing's built-in getCenter and getBounds methods
final center = drawing.getCenter();
final bounds = drawing.getBounds();
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
if (!matchesActiveCustomMap(drawing.mapId)) {
return 'Load the matching custom map to view this drawing.';
}
return navigateToBounds(
bounds: drawing.getBounds(),
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
}
// Calculate appropriate zoom level based on bounds
// For larger drawings, use lower zoom to fit the whole drawing
// For smaller drawings, use higher zoom for better detail
final bounds = drawing.getBounds();
final latDiff = (bounds.north - bounds.south).abs();
final lonDiff = (bounds.east - bounds.west).abs();
final maxDiff = latDiff > lonDiff ? latDiff : lonDiff;
// Zoom scale: smaller drawings get higher zoom
// 0.001 degrees (~100m) -> zoom 17
// 0.005 degrees (~500m) -> zoom 16
// 0.01 degrees (~1km) -> zoom 15
// 0.05 degrees (~5km) -> zoom 13
// 0.1 degrees (~10km) -> zoom 12
double zoom = 15.0;
if (maxDiff < 0.001) {
zoom = 17.0;
@@ -150,9 +239,125 @@ class MapProvider with ChangeNotifier {
zoom = 10.0;
}
final typeStr = drawing is LineDrawing ? 'line' : 'rectangle';
debugPrint('🗺️ [MapProvider] Navigating to drawing: $typeStr, zoom: $zoom');
navigateToLocation(location: center, zoom: zoom, animate: true);
navigateToLocation(
location: drawing.getCenter(),
zoom: zoom,
animate: true,
);
return null;
}
String? navigateToSarMarker(SarMarker marker) {
if (marker.coordinateSpace == MapCoordinateSpace.customMap) {
return navigateToMapPoint(
point: marker.location,
coordinateSpace: MapCoordinateSpace.customMap,
mapId: marker.mapId,
);
}
navigateToLocation(location: marker.location, zoom: 15.0, animate: true);
return null;
}
Future<bool> loadCustomMapFromGallery() async {
final picked = await _imagePicker.pickImage(source: ImageSource.gallery);
if (picked == null) {
return false;
}
final bytes = await picked.readAsBytes();
await setCustomMapImage(bytes: bytes, displayName: picked.name);
return true;
}
Future<void> replaceCustomMap() async {
await loadCustomMapFromGallery();
}
Future<void> setCustomMapImage({
required Uint8List bytes,
required String displayName,
}) async {
final dimensions = await _decodeImageSize(bytes);
final mapId = normalizeCustomMapId(sha256.convert(bytes).toString())!;
final documentsDir = await getApplicationDocumentsDirectory();
final dir = Directory('${documentsDir.path}/$_customMapsDirName');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
final extension = _normalizedExtension(displayName);
final nextPath = '${dir.path}/custom_map_$mapId.$extension';
final nextFile = File(nextPath);
await nextFile.writeAsBytes(bytes, flush: true);
final previousPath = _customMapConfig?.filePath;
_customMapConfig = CustomMapConfig(
filePath: nextPath,
displayName: displayName,
mapId: mapId,
imageWidth: dimensions.$1,
imageHeight: dimensions.$2,
);
_isUsingCustomMap = true;
await _saveCustomMapState();
if (previousPath != null && previousPath != nextPath) {
unawaited(_deleteFileIfExists(previousPath));
}
notifyListeners();
}
Future<void> setCustomMapCalibration({
required LatLng pointA,
required LatLng pointB,
required double metersPerPixel,
}) async {
if (_customMapConfig == null) return;
_customMapConfig = _customMapConfig!.copyWith(
calibrationPointA: pointA,
calibrationPointB: pointB,
metersPerPixel: metersPerPixel,
);
await _saveCustomMapState();
notifyListeners();
}
Future<void> clearCustomMapCalibration() async {
if (_customMapConfig == null) return;
_customMapConfig = _customMapConfig!.copyWith(
clearMetersPerPixel: true,
clearCalibrationPointA: true,
clearCalibrationPointB: true,
);
await _saveCustomMapState();
notifyListeners();
}
Future<void> removeCustomMap() async {
final filePath = _customMapConfig?.filePath;
_customMapConfig = null;
_isUsingCustomMap = false;
clearNavigation();
await _saveCustomMapState();
if (filePath != null) {
await _deleteFileIfExists(filePath);
}
notifyListeners();
}
Future<void> enterCustomMapMode() async {
if (!hasCustomMap || _isUsingCustomMap) return;
_isUsingCustomMap = true;
await _saveCustomMapState();
notifyListeners();
}
Future<void> exitCustomMapMode() async {
if (!_isUsingCustomMap) return;
_isUsingCustomMap = false;
await _saveCustomMapState();
notifyListeners();
}
void updateZoom(double zoom) {
@@ -160,7 +365,6 @@ class MapProvider with ChangeNotifier {
notifyListeners();
}
/// Toggle path visibility for a contact
void toggleContactPath(String publicKeyHex) {
if (_visibleContactPaths.contains(publicKeyHex)) {
_visibleContactPaths.remove(publicKeyHex);
@@ -170,27 +374,22 @@ class MapProvider with ChangeNotifier {
notifyListeners();
}
/// Check if a contact's path is visible
bool isContactPathVisible(String publicKeyHex) {
return _visibleContactPaths.contains(publicKeyHex);
}
/// Hide all contact paths
void hideAllPaths() {
_visibleContactPaths.clear();
notifyListeners();
}
/// Show path for specific contact (hide all others)
void showOnlyPath(String publicKeyHex) {
_visibleContactPaths.clear();
_visibleContactPaths.add(publicKeyHex);
notifyListeners();
}
/// Start a new location trail
void startTrail() {
// End current trail if active
if (_currentTrail != null && _currentTrail!.isActive) {
endTrail();
}
@@ -203,22 +402,22 @@ class MapProvider with ChangeNotifier {
notifyListeners();
}
/// Add a point to the current trail
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
if (_currentTrail == null || !_currentTrail!.isActive) {
startTrail();
}
_currentTrail!.addPoint(TrailPoint(
position: position,
timestamp: DateTime.now(),
accuracy: accuracy,
speed: speed,
));
_currentTrail!.addPoint(
TrailPoint(
position: position,
timestamp: DateTime.now(),
accuracy: accuracy,
speed: speed,
),
);
notifyListeners();
}
/// End the current trail
void endTrail() {
if (_currentTrail != null) {
_currentTrail!.isActive = false;
@@ -231,13 +430,11 @@ class MapProvider with ChangeNotifier {
}
}
/// Toggle trail visibility
void toggleTrailVisibility() {
_isTrailVisible = !_isTrailVisible;
notifyListeners();
}
/// Clear the current trail
void clearCurrentTrail() {
if (_currentTrail != null) {
_currentTrail = null;
@@ -245,158 +442,193 @@ class MapProvider with ChangeNotifier {
}
}
/// Clear all trail history
void clearAllTrails() {
_currentTrail = null;
_trailHistory.clear();
notifyListeners();
}
/// Get total trail distance in meters
double get totalTrailDistance {
if (_currentTrail == null) return 0;
return _currentTrail!.totalDistance;
}
/// Get trail duration
Duration get trailDuration {
if (_currentTrail == null) return Duration.zero;
return _currentTrail!.duration;
}
/// Toggle cadastral parcels overlay
Future<void> toggleCadastralOverlay() async {
_showCadastralOverlay = !_showCadastralOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle forest roads overlay
Future<void> toggleForestRoadsOverlay() async {
_showForestRoadsOverlay = !_showForestRoadsOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle hiking trails overlay
Future<void> toggleHikingTrailsOverlay() async {
_showHikingTrailsOverlay = !_showHikingTrailsOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle main roads overlay
Future<void> toggleMainRoadsOverlay() async {
_showMainRoadsOverlay = !_showMainRoadsOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle house numbers overlay
Future<void> toggleHouseNumbersOverlay() async {
_showHouseNumbersOverlay = !_showHouseNumbersOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle fire hazard zones overlay
Future<void> toggleFireHazardZonesOverlay() async {
_showFireHazardZonesOverlay = !_showFireHazardZonesOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle historical fires overlay
Future<void> toggleHistoricalFiresOverlay() async {
_showHistoricalFiresOverlay = !_showHistoricalFiresOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle firebreaks overlay
Future<void> toggleFirebreaksOverlay() async {
_showFirebreaksOverlay = !_showFirebreaksOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle Kras fire zones overlay
Future<void> toggleKrasFireZonesOverlay() async {
_showKrasFireZonesOverlay = !_showKrasFireZonesOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle place names overlay
Future<void> togglePlaceNamesOverlay() async {
_showPlaceNamesOverlay = !_showPlaceNamesOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Toggle municipality borders overlay
Future<void> toggleMunicipalityBordersOverlay() async {
_showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay;
notifyListeners();
await _saveOverlayState();
}
/// Load overlay state from SharedPreferences
Future<void> loadOverlayState() async {
final prefs = await SharedPreferences.getInstance();
_showCadastralOverlay = prefs.getBool('map_show_cadastral_overlay') ?? false;
_showForestRoadsOverlay = prefs.getBool('map_show_forest_roads_overlay') ?? false;
_showHikingTrailsOverlay = prefs.getBool('map_show_hiking_trails_overlay') ?? false;
_showMainRoadsOverlay = prefs.getBool('map_show_main_roads_overlay') ?? false;
_showHouseNumbersOverlay = prefs.getBool('map_show_house_numbers_overlay') ?? false;
_showFireHazardZonesOverlay = prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false;
_showHistoricalFiresOverlay = prefs.getBool('map_show_historical_fires_overlay') ?? false;
_showFirebreaksOverlay = prefs.getBool('map_show_firebreaks_overlay') ?? false;
_showKrasFireZonesOverlay = prefs.getBool('map_show_kras_fire_zones_overlay') ?? false;
_showPlaceNamesOverlay = prefs.getBool('map_show_place_names_overlay') ?? false;
_showMunicipalityBordersOverlay = prefs.getBool('map_show_municipality_borders_overlay') ?? false;
_showCadastralOverlay =
prefs.getBool(_scopedKey('map_show_cadastral_overlay')) ?? false;
_showForestRoadsOverlay =
prefs.getBool(_scopedKey('map_show_forest_roads_overlay')) ?? false;
_showHikingTrailsOverlay =
prefs.getBool(_scopedKey('map_show_hiking_trails_overlay')) ?? false;
_showMainRoadsOverlay =
prefs.getBool(_scopedKey('map_show_main_roads_overlay')) ?? false;
_showHouseNumbersOverlay =
prefs.getBool(_scopedKey('map_show_house_numbers_overlay')) ?? false;
_showFireHazardZonesOverlay =
prefs.getBool(_scopedKey('map_show_fire_hazard_zones_overlay')) ??
false;
_showHistoricalFiresOverlay =
prefs.getBool(_scopedKey('map_show_historical_fires_overlay')) ?? false;
_showFirebreaksOverlay =
prefs.getBool(_scopedKey('map_show_firebreaks_overlay')) ?? false;
_showKrasFireZonesOverlay =
prefs.getBool(_scopedKey('map_show_kras_fire_zones_overlay')) ?? false;
_showPlaceNamesOverlay =
prefs.getBool(_scopedKey('map_show_place_names_overlay')) ?? false;
_showMunicipalityBordersOverlay =
prefs.getBool(_scopedKey('map_show_municipality_borders_overlay')) ??
false;
notifyListeners();
}
Future<void> _loadInitialState() async {
await Future.wait([loadOverlayState(), loadTrailSettings()]);
await loadRepeaterVisibilitySettings();
await Future.wait([
loadOverlayState(),
loadTrailSettings(),
loadRepeaterVisibilitySettings(),
_loadCustomMapState(),
]);
}
/// Save overlay state to SharedPreferences
Future<void> _saveOverlayState() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay);
await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay);
await prefs.setBool('map_show_hiking_trails_overlay', _showHikingTrailsOverlay);
await prefs.setBool('map_show_main_roads_overlay', _showMainRoadsOverlay);
await prefs.setBool('map_show_house_numbers_overlay', _showHouseNumbersOverlay);
await prefs.setBool('map_show_fire_hazard_zones_overlay', _showFireHazardZonesOverlay);
await prefs.setBool('map_show_historical_fires_overlay', _showHistoricalFiresOverlay);
await prefs.setBool('map_show_firebreaks_overlay', _showFirebreaksOverlay);
await prefs.setBool('map_show_kras_fire_zones_overlay', _showKrasFireZonesOverlay);
await prefs.setBool('map_show_place_names_overlay', _showPlaceNamesOverlay);
await prefs.setBool('map_show_municipality_borders_overlay', _showMunicipalityBordersOverlay);
await prefs.setBool(
_scopedKey('map_show_cadastral_overlay'),
_showCadastralOverlay,
);
await prefs.setBool(
_scopedKey('map_show_forest_roads_overlay'),
_showForestRoadsOverlay,
);
await prefs.setBool(
_scopedKey('map_show_hiking_trails_overlay'),
_showHikingTrailsOverlay,
);
await prefs.setBool(
_scopedKey('map_show_main_roads_overlay'),
_showMainRoadsOverlay,
);
await prefs.setBool(
_scopedKey('map_show_house_numbers_overlay'),
_showHouseNumbersOverlay,
);
await prefs.setBool(
_scopedKey('map_show_fire_hazard_zones_overlay'),
_showFireHazardZonesOverlay,
);
await prefs.setBool(
_scopedKey('map_show_historical_fires_overlay'),
_showHistoricalFiresOverlay,
);
await prefs.setBool(
_scopedKey('map_show_firebreaks_overlay'),
_showFirebreaksOverlay,
);
await prefs.setBool(
_scopedKey('map_show_kras_fire_zones_overlay'),
_showKrasFireZonesOverlay,
);
await prefs.setBool(
_scopedKey('map_show_place_names_overlay'),
_showPlaceNamesOverlay,
);
await prefs.setBool(
_scopedKey('map_show_municipality_borders_overlay'),
_showMunicipalityBordersOverlay,
);
}
/// Toggle all contact trails on/off
Future<void> toggleAllContactTrails() async {
_showAllContactTrails = !_showAllContactTrails;
notifyListeners();
await _saveTrailSettings();
}
/// Load trail settings from SharedPreferences
Future<void> loadTrailSettings() async {
final prefs = await SharedPreferences.getInstance();
_showAllContactTrails = prefs.getBool('map_show_all_contact_trails') ?? true; // Default to true (show all)
_showAllContactTrails =
prefs.getBool(_scopedKey('map_show_all_contact_trails')) ?? true;
notifyListeners();
}
/// Save trail settings to SharedPreferences
Future<void> _saveTrailSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
await prefs.setBool(
_scopedKey('map_show_all_contact_trails'),
_showAllContactTrails,
);
}
Future<void> setHideRepeatersOnMap(bool hide) async {
@@ -404,57 +636,168 @@ class MapProvider with ChangeNotifier {
_hideRepeatersOnMap = hide;
notifyListeners();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap);
await prefs.setBool(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap);
}
Future<void> loadRepeaterVisibilitySettings() async {
final prefs = await SharedPreferences.getInstance();
_hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false;
_hideRepeatersOnMap =
prefs.getBool(_scopedKey('map_hide_repeaters')) ?? false;
notifyListeners();
}
/// Set imported trail (from GPX import)
void setImportedTrail(LocationTrail trail) {
_importedTrail = trail;
notifyListeners();
}
/// Clear imported trail
void clearImportedTrail() {
_importedTrail = null;
notifyListeners();
}
/// Replace current trail with imported trail
void replaceCurrentTrailWithImport(LocationTrail importedTrail) {
// End current trail if active
if (_currentTrail != null && _currentTrail!.isActive) {
endTrail();
}
// Set imported trail as current trail
_currentTrail = importedTrail;
_isTrailVisible = true;
notifyListeners();
}
/// Enter download area selection mode with initial bounds
void enterDownloadAreaMode(LatLngBounds initialBounds) {
_isSelectingDownloadArea = true;
_downloadAreaBounds = initialBounds;
notifyListeners();
}
/// Exit download area selection mode
void exitDownloadAreaMode() {
_isSelectingDownloadArea = false;
_downloadAreaBounds = null;
notifyListeners();
}
/// Update the download area bounds (while dragging/resizing)
void updateDownloadAreaBounds(LatLngBounds bounds) {
_downloadAreaBounds = bounds;
notifyListeners();
}
Future<void> _loadCustomMapState() async {
final prefs = await SharedPreferences.getInstance();
final configJson = prefs.getString(_scopedKey(_customMapConfigKey));
if (configJson != null && configJson.isNotEmpty) {
final decoded = jsonDecode(configJson);
if (decoded is Map<String, dynamic>) {
final config = CustomMapConfig.fromJson(decoded);
if (config != null && await File(config.filePath).exists()) {
_customMapConfig = config;
} else {
_customMapConfig = null;
}
}
} else {
_customMapConfig = null;
}
_isUsingCustomMap =
(prefs.getBool(_scopedKey(_customMapModeKey)) ?? false) &&
_customMapConfig != null;
notifyListeners();
}
Future<void> _saveCustomMapState() async {
final prefs = await SharedPreferences.getInstance();
if (_customMapConfig == null) {
await prefs.remove(_scopedKey(_customMapConfigKey));
} else {
await prefs.setString(
_scopedKey(_customMapConfigKey),
jsonEncode(_customMapConfig!.toJson()),
);
}
await prefs.setBool(_scopedKey(_customMapModeKey), _isUsingCustomMap);
}
Future<(int, int)> _decodeImageSize(Uint8List bytes) async {
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
return (frame.image.width, frame.image.height);
}
String _normalizedExtension(String displayName) {
final dotIndex = displayName.lastIndexOf('.');
if (dotIndex == -1 || dotIndex == displayName.length - 1) {
return 'png';
}
return displayName.substring(dotIndex + 1).toLowerCase();
}
Future<void> _deleteFileIfExists(String path) async {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
Map<String, dynamic> exportWorkspaceJson() {
return {
'currentTrail': _currentTrail?.toJson(),
'trailHistory': _trailHistory.map((trail) => trail.toJson()).toList(),
'importedTrail': _importedTrail?.toJson(),
'isTrailVisible': _isTrailVisible,
'showCadastralOverlay': _showCadastralOverlay,
'showForestRoadsOverlay': _showForestRoadsOverlay,
'showHikingTrailsOverlay': _showHikingTrailsOverlay,
'showMainRoadsOverlay': _showMainRoadsOverlay,
'showHouseNumbersOverlay': _showHouseNumbersOverlay,
'showFireHazardZonesOverlay': _showFireHazardZonesOverlay,
'showHistoricalFiresOverlay': _showHistoricalFiresOverlay,
'showFirebreaksOverlay': _showFirebreaksOverlay,
'showKrasFireZonesOverlay': _showKrasFireZonesOverlay,
'showPlaceNamesOverlay': _showPlaceNamesOverlay,
'showMunicipalityBordersOverlay': _showMunicipalityBordersOverlay,
'showAllContactTrails': _showAllContactTrails,
'hideRepeatersOnMap': _hideRepeatersOnMap,
};
}
void applyWorkspaceJson(Map<String, dynamic> json) {
_currentTrail = json['currentTrail'] is Map<String, dynamic>
? LocationTrail.fromJson(json['currentTrail'] as Map<String, dynamic>)
: null;
_trailHistory
..clear()
..addAll(
(json['trailHistory'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(LocationTrail.fromJson),
);
_importedTrail = json['importedTrail'] is Map<String, dynamic>
? LocationTrail.fromJson(json['importedTrail'] as Map<String, dynamic>)
: null;
_isTrailVisible = json['isTrailVisible'] as bool? ?? true;
_showCadastralOverlay = json['showCadastralOverlay'] as bool? ?? false;
_showForestRoadsOverlay = json['showForestRoadsOverlay'] as bool? ?? false;
_showHikingTrailsOverlay =
json['showHikingTrailsOverlay'] as bool? ?? false;
_showMainRoadsOverlay = json['showMainRoadsOverlay'] as bool? ?? false;
_showHouseNumbersOverlay =
json['showHouseNumbersOverlay'] as bool? ?? false;
_showFireHazardZonesOverlay =
json['showFireHazardZonesOverlay'] as bool? ?? false;
_showHistoricalFiresOverlay =
json['showHistoricalFiresOverlay'] as bool? ?? false;
_showFirebreaksOverlay = json['showFirebreaksOverlay'] as bool? ?? false;
_showKrasFireZonesOverlay =
json['showKrasFireZonesOverlay'] as bool? ?? false;
_showPlaceNamesOverlay = json['showPlaceNamesOverlay'] as bool? ?? false;
_showMunicipalityBordersOverlay =
json['showMunicipalityBordersOverlay'] as bool? ?? false;
_showAllContactTrails = json['showAllContactTrails'] as bool? ?? true;
_hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false;
notifyListeners();
}
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
}

View File

@@ -18,6 +18,8 @@ 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);
@@ -28,11 +30,14 @@ class MessagesProvider with ChangeNotifier {
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 = {};
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
String? _storageNamespace;
// Track pending sent messages by expected ACK/TAG
final Map<int, Message> _pendingSentMessages = {};
@@ -91,6 +96,10 @@ class MessagesProvider with ChangeNotifier {
Future<void> Function({required Contact contact, required int failureStreak})?
onDirectPathFailedCallback;
/// Called before the last retry attempt to reset the contact's path,
/// forcing the firmware to use flood mode for the final try.
Future<void> Function(Contact contact)? resetPathBeforeLastRetryCallback;
void Function(String messageId)? onManualRetryPreparedCallback;
Future<bool> Function({
required String messageId,
@@ -145,6 +154,7 @@ class MessagesProvider with ChangeNotifier {
sarMarkers.where((m) => m.type == SarMarkerType.object).toList();
bool get isInitialized => _isInitialized;
String? get storageNamespace => _storageNamespace;
String? get targetMessageId => _targetMessageId;
@@ -307,19 +317,64 @@ class MessagesProvider with ChangeNotifier {
Future<void> initialize() async {
if (_isInitialized) return;
await _loadFromStorage();
}
Future<void> reloadFromStorage({String? namespace}) async {
_storageNamespace = namespace;
await _loadFromStorage(force: true);
}
Future<void> persistNow() async {
await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata,
namespace: _storageNamespace,
);
await _storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
);
}
Future<void> cloneCurrentStorageTo(String? namespace) async {
await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
messageRouteMetadata: _messageRouteMetadata,
namespace: namespace,
);
await _storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: namespace,
);
}
Future<void> _loadFromStorage({bool force = false}) async {
if (_isInitialized && !force) return;
try {
_cancelAllTimers();
_resetInMemoryState();
debugPrint('📦 [MessagesProvider] Loading persisted messages...');
final storedMessages = await _storageService.loadMessages();
final storedMessages = await _storageService.loadMessages(
namespace: _storageNamespace,
);
final storedContactLocations = await _storageService
.loadMessageContactLocations();
.loadMessageContactLocations(namespace: _storageNamespace);
final storedReceptionDetails = await _storageService
.loadMessageReceptionDetails();
.loadMessageReceptionDetails(namespace: _storageNamespace);
final storedTransferDetails = await _storageService
.loadMessageTransferDetails();
.loadMessageTransferDetails(namespace: _storageNamespace);
final storedRouteMetadata = await _storageService
.loadMessageRouteMetadata();
.loadMessageRouteMetadata(namespace: _storageNamespace);
final storedRemovedSarMarkerIds = await _storageService
.loadRemovedSarMarkerIds();
.loadRemovedSarMarkerIds(namespace: _storageNamespace);
_messageContactLocations
..clear()
..addAll(storedContactLocations);
@@ -549,6 +604,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(
@@ -580,8 +644,21 @@ class MessagesProvider with ChangeNotifier {
if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot;
}
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
incoming: receptionDetailsSnapshot,
);
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
final existingMessage = _messages[duplicateIndex];
final duplicatePathBytes = receptionDetailsSnapshot.pathBytes;
if (existingMessage.pathBytes == null &&
duplicatePathBytes != null &&
duplicatePathBytes.isNotEmpty) {
_messages[duplicateIndex] = existingMessage.copyWith(
pathBytes: Uint8List.fromList(duplicatePathBytes),
);
}
}
_persistMessages();
return; // Skip duplicate
@@ -658,7 +735,10 @@ class MessagesProvider with ChangeNotifier {
}
if (message.isContactMessage) {
return existing.senderKeyShort == message.senderKeyShort;
// Match by sender key + sender timestamp (matches official app's DB
// uniqueness: contactPublicKey + senderTimestamp + text + txtType).
return existing.senderKeyShort == message.senderKeyShort &&
existing.senderTimestamp == message.senderTimestamp;
}
if (message.isChannelMessage) {
@@ -666,56 +746,41 @@ class MessagesProvider with ChangeNotifier {
return false;
}
// Primary dedup: same senderTimestamp = same message from mesh repeats.
// Matches official app's DB uniqueness: (channelSecret, senderTimestamp, text).
if (existing.senderTimestamp == message.senderTimestamp) {
return true;
}
// Secondary: catch near-duplicate repeats within a 30s window
// (clock drift between nodes).
final withinChannelRepeatWindow =
(existing.senderTimestamp - message.senderTimestamp).abs() <= 30;
if (!withinChannelRepeatWindow) {
return false;
}
final existingSenderKey = existing.senderKeyShort;
final incomingSenderKey = message.senderKeyShort;
if (existingSenderKey != null &&
incomingSenderKey != null &&
existingSenderKey == incomingSenderKey &&
withinChannelRepeatWindow) {
existingSenderKey == incomingSenderKey) {
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 true;
}
if (!withinChannelRepeatWindow) {
return false;
}
// Mirror meshcore-open behavior for self-authored channel messages:
// zero-hop self replays are dropped earlier in AppProvider, while
// routed self replays should fold into the local sent bubble when the
// public sender name matches within a short window.
if (existing.isSentMessage &&
!message.isSentMessage &&
existingSenderName == incomingSenderName) {
return true;
}
return true;
}
return false;
}
return true;
// System messages and other types: never deduplicate by scope alone.
return false;
}
/// Add multiple messages
@@ -725,10 +790,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 (_findDuplicateMessageIndex(enhancedMessage) != -1) {
final duplicateIndex = _findDuplicateMessageIndex(enhancedMessage);
if (duplicateIndex != -1) {
final existingId = _messages[duplicateIndex].id;
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
);
duplicateCount++;
continue; // Skip duplicate
}
@@ -973,18 +1070,32 @@ 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,
namespace: _storageNamespace,
);
}
} catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
} finally {
_isPersisting = false;
}
}
@@ -1028,6 +1139,7 @@ class MessagesProvider with ChangeNotifier {
for (final messageId in messageIdsToRemove) {
_timeoutTimers[messageId]?.cancel();
_timeoutTimers.remove(messageId);
_clearChannelSendWarning(messageId);
_messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId);
@@ -1062,6 +1174,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);
@@ -1097,7 +1238,10 @@ class MessagesProvider with ChangeNotifier {
Future<void> removeSarMarker(String id) async {
_sarMarkers.remove(id);
_removedSarMarkerIds.add(id);
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
await _storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
);
notifyListeners();
}
@@ -1222,7 +1366,12 @@ class MessagesProvider with ChangeNotifier {
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
_persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
unawaited(
_storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
),
);
notifyListeners();
}
}
@@ -1245,20 +1394,16 @@ class MessagesProvider with ChangeNotifier {
/// Clear all messages
void clearMessages() {
for (final timer in _channelEchoWarningTimers.values) {
timer.cancel();
}
_channelEchoWarningTimers.clear();
_channelEchoWarningMessageIds.clear();
_messages.clear();
_sarMarkers.clear();
_removedSarMarkerIds.clear();
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_messageRouteMetadata.clear();
_cancelAllTimers();
_resetInMemoryState();
_retryManager.clearAll();
_persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
unawaited(
_storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
),
);
notifyListeners();
}
@@ -1266,17 +1411,29 @@ class MessagesProvider with ChangeNotifier {
Future<void> clearSarMarkers() async {
_removedSarMarkerIds.addAll(_sarMarkers.keys);
_sarMarkers.clear();
await _storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds);
await _storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
);
notifyListeners();
}
/// Clear all data
void clearAll() {
for (final timer in _channelEchoWarningTimers.values) {
timer.cancel();
}
_channelEchoWarningTimers.clear();
_channelEchoWarningMessageIds.clear();
_cancelAllTimers();
_resetInMemoryState();
_retryManager.clearAll();
_persistMessages();
unawaited(
_storageService.saveRemovedSarMarkerIds(
_removedSarMarkerIds,
namespace: _storageNamespace,
),
);
notifyListeners();
}
void _resetInMemoryState() {
_messages.clear();
_sarMarkers.clear();
_removedSarMarkerIds.clear();
@@ -1284,9 +1441,13 @@ class MessagesProvider with ChangeNotifier {
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_messageRouteMetadata.clear();
_persistMessages();
unawaited(_storageService.saveRemovedSarMarkerIds(_removedSarMarkerIds));
notifyListeners();
_pendingSentMessages.clear();
_messageContactMap.clear();
_groupedMessageMapping.clear();
_ackTagToRecipients.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
_completedAckHistory.clear();
}
int transferCountForSession({
@@ -1964,7 +2125,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(),
@@ -2146,7 +2307,7 @@ class MessagesProvider with ChangeNotifier {
final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt);
debugPrint(
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId',
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttemptsForContact(contact)} for message $messageId',
);
debugPrint(' Delay: ${delayMs}ms');
@@ -2186,6 +2347,17 @@ class MessagesProvider with ChangeNotifier {
return;
}
// On the last attempt, reset the path to force flood mode
// (matches official MeshCore app behaviour)
if (_retryManager.isLastAttempt(currentMessage, contact)) {
debugPrint(
'🔄 [MessagesProvider] Last attempt — resetting path to flood for $messageId',
);
if (resetPathBeforeLastRetryCallback != null) {
await resetPathBeforeLastRetryCallback!(contact);
}
}
if (sendMessageCallback != null) {
final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey,
@@ -2323,11 +2495,14 @@ 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,
isSarMarker: message.isSarMarker,
sarGpsCoordinates: message.sarGpsCoordinates,
sarCustomMapPoint: message.sarCustomMapPoint,
sarCustomMapId: message.sarCustomMapId,
sarNotes: message.sarNotes,
sarCustomEmoji: message.sarCustomEmoji,
sarColorIndex: message.sarColorIndex,
@@ -2410,7 +2585,19 @@ class MessagesProvider with ChangeNotifier {
@override
void dispose() {
// Cancel all pending timeout timers
_cancelAllTimers();
_completedAckHistory.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
// Clear retry manager
_retryManager.clearAll();
super.dispose();
}
/// Cancel all pending timers (timeout + echo warning).
void _cancelAllTimers() {
for (final timer in _timeoutTimers.values) {
timer.cancel();
}
@@ -2420,14 +2607,6 @@ class MessagesProvider with ChangeNotifier {
}
_channelEchoWarningTimers.clear();
_channelEchoWarningMessageIds.clear();
_completedAckHistory.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
// Clear retry manager
_retryManager.clearAll();
super.dispose();
}
void _rememberCompletedAck(int ackCode) {

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import '../utils/avatar_label_helper.dart';
import '../widgets/common/contact_avatar.dart';
import '../widgets/contacts/contact_tile.dart';
import '../widgets/contacts/add_channel_dialog.dart';
import 'add_contact_screen.dart';
class ContactsTab extends StatefulWidget {
final VoidCallback? onNavigateToMap;
@@ -34,11 +35,10 @@ class ContactsTab extends StatefulWidget {
class _ContactsTabState extends State<ContactsTab> {
Position? _currentPosition;
final Set<String> _resolvingAdvertKeys = <String>{};
bool _isResolvingPendingBatch = false;
final Map<ContactSection, String> _sectionFilters = {
ContactSection.teamMembers: '',
ContactSection.repeaters: '',
ContactSection.sensors: '',
ContactSection.rooms: '',
ContactSection.channels: '',
};
@@ -46,6 +46,7 @@ class _ContactsTabState extends State<ContactsTab> {
final Map<ContactSection, ContactSortMode> _sortModes = {
ContactSection.teamMembers: ContactSortMode.lastSeen,
ContactSection.repeaters: ContactSortMode.lastSeen,
ContactSection.sensors: ContactSortMode.lastSeen,
ContactSection.rooms: ContactSortMode.lastSeen,
};
@@ -99,57 +100,6 @@ class _ContactsTabState extends State<ContactsTab> {
await _getCurrentLocation();
}
Future<void> _handleResolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
setState(() {
_resolvingAdvertKeys.add(keyHex);
});
try {
await context.read<ConnectionProvider>().getContact(advert.publicKey);
} finally {
if (mounted) {
setState(() {
_resolvingAdvertKeys.remove(keyHex);
});
}
}
}
void _schedulePendingAdvertResolution(
List<PendingAdvert> pendingAdverts,
ConnectionProvider connectionProvider,
) {
if (_isResolvingPendingBatch ||
!connectionProvider.deviceInfo.isConnected ||
pendingAdverts.isEmpty) {
return;
}
final advertsToResolve = pendingAdverts
.where((advert) => !_resolvingAdvertKeys.contains(advert.publicKeyHex))
.toList();
if (advertsToResolve.isEmpty) {
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted || _isResolvingPendingBatch) return;
_isResolvingPendingBatch = true;
try {
for (final advert in advertsToResolve) {
if (!mounted) break;
await _handleResolveAdvert(advert);
}
} finally {
_isResolvingPendingBatch = false;
}
});
}
/// Calculate distance between two points in meters
double _calculateDistanceInMeters(
double lat1,
@@ -181,15 +131,6 @@ class _ContactsTabState extends State<ContactsTab> {
}
}
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);
}
List<Contact> _filterContactsForSection(
List<Contact> contacts,
ContactSection section,
@@ -449,6 +390,12 @@ class _ContactsTabState extends State<ContactsTab> {
);
}
Future<void> _openAddContactScreen(BuildContext context) async {
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => const AddContactScreen()));
}
Future<void> _showDeleteChannelDialog(
BuildContext context,
Contact channel,
@@ -546,7 +493,7 @@ class _ContactsTabState extends State<ContactsTab> {
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.message_outlined),
leading: Icon(Icons.message_outlined),
title: Text(l10n.messages),
onTap: () async {
Navigator.pop(sheetContext);
@@ -555,7 +502,7 @@ class _ContactsTabState extends State<ContactsTab> {
),
if (channel.displayLocation != null)
ListTile(
leading: const Icon(Icons.map_outlined),
leading: Icon(Icons.map_outlined),
title: Text(l10n.viewOnMap),
onTap: () {
Navigator.pop(sheetContext);
@@ -564,7 +511,7 @@ class _ContactsTabState extends State<ContactsTab> {
),
if (!channel.isPublicChannel)
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
leading: Icon(Icons.delete, color: Colors.red),
title: Text(
l10n.deleteChannel,
style: const TextStyle(color: Colors.red),
@@ -593,7 +540,6 @@ class _ContactsTabState extends State<ContactsTab> {
body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final messagesProvider = context.watch<MessagesProvider>();
final connectionProvider = context.watch<ConnectionProvider>();
final allChatContacts = _sortContacts(
contactsProvider.chatContacts,
ContactSection.teamMembers,
@@ -602,6 +548,10 @@ class _ContactsTabState extends State<ContactsTab> {
contactsProvider.repeaters,
ContactSection.repeaters,
);
final allSensors = _sortContacts(
contactsProvider.sensorContacts,
ContactSection.sensors,
);
final allRooms = _sortContacts(
contactsProvider.rooms,
ContactSection.rooms,
@@ -643,6 +593,19 @@ class _ContactsTabState extends State<ContactsTab> {
final showRepeatersOthersGroup =
visibleSavedRepeaterGroups.length > 1 &&
ungroupedRepeaters.isNotEmpty;
final sensors = _filterContactsForSection(
allSensors,
ContactSection.sensors,
);
final savedSensorGroups = _buildSavedGroupsForSection(
contactsProvider,
allSensors,
ContactSection.sensors,
);
final visibleSavedSensorGroups =
_showSavedGroupsForSection(ContactSection.sensors)
? savedSensorGroups
: const <_RenderedSavedGroup>[];
final rooms = _filterContactsForSection(
allRooms,
ContactSection.rooms,
@@ -669,36 +632,18 @@ class _ContactsTabState extends State<ContactsTab> {
_showSavedGroupsForSection(ContactSection.channels)
? savedChannelGroups
: const <_RenderedSavedGroup>[];
final showTeamMembersSection =
allChatContacts.isNotEmpty &&
(!_sectionHasActiveFilter(ContactSection.teamMembers) ||
chatContacts.isNotEmpty ||
visibleSavedTeamGroups.isNotEmpty);
final showRepeatersSection =
allRepeaters.isNotEmpty &&
(!_sectionHasActiveFilter(ContactSection.repeaters) ||
repeaters.isNotEmpty ||
visibleSavedRepeaterGroups.isNotEmpty);
final showRoomsSection =
allRooms.isNotEmpty &&
(!_sectionHasActiveFilter(ContactSection.rooms) ||
rooms.isNotEmpty ||
visibleSavedRoomGroups.isNotEmpty);
final showChannelsSection =
!_sectionHasActiveFilter(ContactSection.channels) ||
filteredChannels.isNotEmpty ||
visibleSavedChannelGroups.isNotEmpty;
final pendingAdverts = contactsProvider.pendingAdverts;
_schedulePendingAdvertResolution(pendingAdverts, connectionProvider);
final showTeamMembersSection = allChatContacts.isNotEmpty;
final showRepeatersSection = allRepeaters.isNotEmpty;
final showSensorsSection = allSensors.isNotEmpty;
final showRoomsSection = allRooms.isNotEmpty;
final showChannelsSection = allChannels.isNotEmpty;
// Check if there are any displayable contacts
final hasDisplayableContacts =
allChatContacts.isNotEmpty ||
allRepeaters.isNotEmpty ||
allSensors.isNotEmpty ||
allRooms.isNotEmpty ||
allChannels.isNotEmpty ||
pendingAdverts.isNotEmpty;
allChannels.isNotEmpty;
if (!hasDisplayableContacts) {
return Center(
@@ -710,17 +655,29 @@ class _ContactsTabState extends State<ContactsTab> {
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
SizedBox(height: 16),
Text(
l10n.noContactsYet,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
SizedBox(height: 8),
Text(
l10n.connectToDeviceToLoadContacts,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
if (context
.watch<ConnectionProvider>()
.deviceInfo
.isConnected)
Padding(
padding: const EdgeInsets.only(top: 16),
child: OutlinedButton.icon(
onPressed: () => _openAddContactScreen(context),
icon: Icon(Icons.person_add_alt_1_outlined),
label: Text(l10n.addContact),
),
),
],
),
);
@@ -731,6 +688,19 @@ class _ContactsTabState extends State<ContactsTab> {
child: ListView(
padding: const EdgeInsets.all(8),
children: [
// Favourites (contacts with firmware favourite flag set)
if (contactsProvider.favouriteContacts.isNotEmpty) ...[
_SectionHeader(
title: l10n.favourites,
count: contactsProvider.favouriteContacts.length,
icon: Icons.star,
),
..._buildContactSectionItems(
contactsProvider.favouriteContacts,
),
const Divider(height: 32),
],
// Team Members (Chat contacts)
if (showTeamMembersSection) ...[
_SectionHeader(
@@ -761,12 +731,16 @@ class _ContactsTabState extends State<ContactsTab> {
visibleSavedTeamGroups,
ContactSection.teamMembers,
),
..._buildContactSectionItems(
_excludeGroupedContacts(
chatContacts,
visibleSavedTeamGroups,
if (chatContacts.isEmpty &&
_sectionHasActiveFilter(ContactSection.teamMembers))
_buildNoFilterResults(context)
else
..._buildContactSectionItems(
_excludeGroupedContacts(
chatContacts,
visibleSavedTeamGroups,
),
),
),
const Divider(height: 32),
],
@@ -776,7 +750,31 @@ class _ContactsTabState extends State<ContactsTab> {
title: l10n.repeaters,
count: repeaters.length,
icon: Icons.router,
trailing: _buildSortMenu(context, ContactSection.repeaters),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (context
.watch<ConnectionProvider>()
.deviceInfo
.isConnected)
IconButton(
icon: const Icon(Icons.radar, size: 20),
tooltip: 'Discover repeaters',
visualDensity: VisualDensity.compact,
onPressed: () {
context
.read<ConnectionProvider>()
.discoverNodeType(advertType: 2);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.repeaterDiscoverySent),
),
);
},
),
_buildSortMenu(context, ContactSection.repeaters),
],
),
),
_buildSectionFilterField(
context,
@@ -799,11 +797,13 @@ class _ContactsTabState extends State<ContactsTab> {
visibleSavedRepeaterGroups,
ContactSection.repeaters,
),
if (showRepeatersOthersGroup)
if (repeaters.isEmpty &&
_sectionHasActiveFilter(ContactSection.repeaters))
_buildNoFilterResults(context)
else if (showRepeatersOthersGroup)
_InferredContactGroupCard(
label: 'Others',
label: l10n.others,
contacts: ungroupedRepeaters,
kindLabel: 'Auto group',
compactContacts: true,
currentPosition: _currentPosition,
calculateDistance: _calculateDistanceInMeters,
@@ -819,6 +819,60 @@ class _ContactsTabState extends State<ContactsTab> {
const Divider(height: 32),
],
// Sensors
if (showSensorsSection) ...[
_SectionHeader(
title: l10n.sensors,
count: sensors.length,
icon: Icons.sensors,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (context
.watch<ConnectionProvider>()
.deviceInfo
.isConnected)
IconButton(
icon: const Icon(Icons.radar, size: 20),
tooltip: 'Discover sensors',
visualDensity: VisualDensity.compact,
onPressed: () {
context
.read<ConnectionProvider>()
.discoverNodeType(advertType: 4);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.sensorDiscoverySent),
),
);
},
),
_buildSortMenu(context, ContactSection.sensors),
],
),
),
_buildSectionFilterField(
context,
ContactSection.sensors,
contactsProvider,
),
..._buildSavedGroupCards(
visibleSavedSensorGroups,
ContactSection.sensors,
),
if (sensors.isEmpty &&
_sectionHasActiveFilter(ContactSection.sensors))
_buildNoFilterResults(context)
else
..._buildContactSectionItems(
_excludeGroupedContacts(
sensors,
visibleSavedSensorGroups,
),
),
const Divider(height: 32),
],
// Rooms
if (showRoomsSection) ...[
_SectionHeader(
@@ -836,30 +890,13 @@ class _ContactsTabState extends State<ContactsTab> {
visibleSavedRoomGroups,
ContactSection.rooms,
),
..._buildContactSectionItems(
_excludeGroupedContacts(rooms, visibleSavedRoomGroups),
),
const Divider(height: 32),
],
// Pending adverts are kept below resolved sections while we load details.
if (pendingAdverts.isNotEmpty) ...[
_SectionHeader(
title: l10n.pending,
count: pendingAdverts.length,
icon: Icons.person_search,
),
...pendingAdverts.map(
(advert) => _PendingAdvertTile(
advert: advert,
subtitle:
'${l10n.publicKey}: ${advert.shortDisplayKey}\n${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
isResolving: _resolvingAdvertKeys.contains(
advert.publicKeyHex,
),
onResolve: () => _handleResolveAdvert(advert),
if (rooms.isEmpty &&
_sectionHasActiveFilter(ContactSection.rooms))
_buildNoFilterResults(context)
else
..._buildContactSectionItems(
_excludeGroupedContacts(rooms, visibleSavedRoomGroups),
),
),
const Divider(height: 32),
],
@@ -879,7 +916,10 @@ class _ContactsTabState extends State<ContactsTab> {
visibleSavedChannelGroups,
ContactSection.channels,
),
if (filteredChannels.isNotEmpty) ...[
if (filteredChannels.isEmpty &&
_sectionHasActiveFilter(ContactSection.channels))
_buildNoFilterResults(context)
else ...[
..._excludeGroupedContacts(
filteredChannels,
visibleSavedChannelGroups,
@@ -901,16 +941,36 @@ class _ContactsTabState extends State<ContactsTab> {
horizontal: 16,
vertical: 8,
),
child: OutlinedButton.icon(
onPressed: () => _showAddChannelDialog(context),
icon: const Icon(Icons.add_circle_outline),
label: Text(l10n.addChannel),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _openAddContactScreen(context),
icon: Icon(Icons.person_add_alt_1_outlined),
label: Text(l10n.addContact),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _showAddChannelDialog(context),
icon: Icon(Icons.add_circle_outline),
label: Text(l10n.addChannel),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
),
],
),
),
],
@@ -921,6 +981,18 @@ class _ContactsTabState extends State<ContactsTab> {
);
}
Widget _buildNoFilterResults(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
child: Text(
'No matches',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
List<Widget> _buildContactSectionItems(
List<Contact> contacts, {
bool compact = false,
@@ -972,7 +1044,6 @@ class _ContactsTabState extends State<ContactsTab> {
onDelete: () => context
.read<ContactsProvider>()
.removeSavedGroupById(group.group.id),
kindLabel: group.group.isAutoGroup ? 'Auto group' : 'Saved filter',
),
)
.toList();
@@ -1214,7 +1285,7 @@ class _ContactsTabState extends State<ContactsTab> {
? colorScheme.primary
: null,
),
const SizedBox(width: 8),
SizedBox(width: 8),
Text(l10n.lastSeen),
],
),
@@ -1230,7 +1301,7 @@ class _ContactsTabState extends State<ContactsTab> {
? colorScheme.primary
: null,
),
const SizedBox(width: 8),
SizedBox(width: 8),
Text(l10n.distance),
],
),
@@ -1250,47 +1321,7 @@ class _ContactsTabState extends State<ContactsTab> {
enum ContactSortMode { lastSeen, distance }
enum ContactSection { teamMembers, repeaters, rooms, channels }
class _PendingAdvertTile extends StatelessWidget {
final PendingAdvert advert;
final String subtitle;
final bool isResolving;
final VoidCallback onResolve;
const _PendingAdvertTile({
required this.advert,
required this.subtitle,
required this.isResolving,
required this.onResolve,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const CircleAvatar(child: Icon(Icons.campaign_outlined)),
title: Text(
advert.shortDisplayKey,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(subtitle),
trailing: isResolving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: IconButton(
icon: const Icon(Icons.person_add_alt_1),
tooltip: 'Resolve contact',
onPressed: onResolve,
),
),
);
}
}
enum ContactSection { teamMembers, repeaters, sensors, rooms, channels }
class _RenderedSavedGroup {
final SavedContactGroup group;
@@ -1348,7 +1379,6 @@ class _SectionHeader extends StatelessWidget {
class _InferredContactGroupCard extends StatelessWidget {
final String label;
final List<Contact> contacts;
final String? kindLabel;
final bool compactContacts;
final Position? currentPosition;
final double Function(double, double, double, double) calculateDistance;
@@ -1360,7 +1390,6 @@ class _InferredContactGroupCard extends StatelessWidget {
const _InferredContactGroupCard({
required this.label,
required this.contacts,
this.kindLabel,
this.compactContacts = false,
required this.currentPosition,
required this.calculateDistance,
@@ -1375,10 +1404,10 @@ class _InferredContactGroupCard extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 12),
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
),
@@ -1386,40 +1415,29 @@ class _InferredContactGroupCard extends StatelessWidget {
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
tilePadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
childrenPadding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
minTileHeight: 44,
initiallyExpanded: false,
leading: Icon(
Icons.folder_copy_outlined,
size: 18,
size: 16,
color: colorScheme.primary,
),
title: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
if (kindLabel case final value?)
Text(
value,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
],
child: Text(
label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
),
const SizedBox(width: 8),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(999),
@@ -1430,13 +1448,19 @@ class _InferredContactGroupCard extends StatelessWidget {
),
),
if (onDelete != null) ...[
const SizedBox(width: 4),
const SizedBox(width: 2),
IconButton(
tooltip: 'Delete group',
onPressed: onDelete,
visualDensity: VisualDensity.compact,
constraints: const BoxConstraints.tightFor(
width: 32,
height: 32,
),
padding: EdgeInsets.zero,
icon: Icon(
Icons.delete_outline_rounded,
size: 18,
size: 16,
color: colorScheme.error,
),
),
@@ -1447,7 +1471,6 @@ class _InferredContactGroupCard extends StatelessWidget {
...contacts.map(
(contact) => ContactTile(
contact: contact,
groupLabel: label,
compact: compactContacts,
currentPosition: currentPosition,
calculateDistance: calculateDistance,

View File

@@ -1,8 +1,15 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import '../models/device_info.dart';
import '../models/channel.dart';
import '../models/contact.dart';
import '../providers/channels_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/validation_service.dart';
import '../l10n/app_localizations.dart';
@@ -14,6 +21,11 @@ class DeviceConfigScreen extends StatefulWidget {
}
class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
static const int _bulkDeleteBatchSize = 8;
static const Duration _bulkDeleteInterItemDelay = Duration(milliseconds: 120);
static const Duration _bulkDeleteBatchDelay = Duration(milliseconds: 700);
static const Duration _bulkDeleteFinalSyncDelay = Duration(milliseconds: 900);
static const int _autoAddFilterModeFlag = 1;
static const List<_RadioPreset> _radioPresets = [
_RadioPreset(
id: 'australia',
@@ -166,16 +178,32 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
late TextEditingController _lonController;
late TextEditingController _freqController;
late TextEditingController _txPowerController;
late final ConnectionProvider _connectionProvider;
bool _telemetryEnabled = false;
bool _repeatEnabled = false;
bool? _gpsEnabled; // null = not supported by hardware
bool _gpsLoading = false;
bool _autoAddDiscoveredContactsEnabled = true;
bool _autoAddUsersEnabled = true;
bool _autoAddRepeatersEnabled = true;
bool _autoAddRoomServersEnabled = true;
bool _autoAddSensorsEnabled = true;
bool _overwriteOldestAutoAddEnabled = false;
bool _showCustomRadioSettings = false;
bool _isSavingPublicInfo = false;
bool _isSavingRadioSettings = false;
bool _isSavingAutoDiscoverySettings = false;
bool _isClearingContacts = false;
bool _isClearingChannels = false;
bool _publicInfoSaved = false;
bool _radioSettingsSaved = false;
bool _autoDiscoverySettingsSaved = false;
bool _autoDiscoverySettingsDirty = false;
String? _lastAutoDiscoverySignature;
String? _publicInfoError;
String? _radioSettingsError;
String? _autoDiscoverySettingsError;
String _selectedBandwidth = '62.5 kHz';
int _selectedSpreadingFactor = 8;
int _selectedCodingRate = 8;
@@ -197,7 +225,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
@override
void initState() {
super.initState();
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
_connectionProvider = context.read<ConnectionProvider>();
_connectionProvider.addListener(_handleConnectionProviderChanged);
final deviceInfo = _connectionProvider.deviceInfo;
_nameController = TextEditingController(
text: deviceInfo.selfName ?? deviceInfo.deviceName ?? '',
@@ -252,6 +282,8 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Initialize repeat mode from device info (firmware v9+)
_repeatEnabled = deviceInfo.clientRepeat ?? false;
_syncAutoDiscoveryState(deviceInfo);
_lastAutoDiscoverySignature = _autoDiscoverySignature(deviceInfo);
// Fetch allowed repeat frequencies on open if device supports repeat mode
if (deviceInfo.clientRepeat != null &&
@@ -262,12 +294,15 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ConnectionProvider>().getBatteryAndStorage();
_connectionProvider.getBatteryAndStorage();
unawaited(_connectionProvider.getAutoaddConfig());
unawaited(_loadGpsMode());
});
}
@override
void dispose() {
_connectionProvider.removeListener(_handleConnectionProviderChanged);
_nameController.dispose();
_latController.dispose();
_lonController.dispose();
@@ -375,9 +410,83 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
}
void _markAutoDiscoverySettingsDirty() {
_autoDiscoverySettingsDirty = true;
if (_autoDiscoverySettingsSaved || _autoDiscoverySettingsError != null) {
setState(() {
_autoDiscoverySettingsSaved = false;
_autoDiscoverySettingsError = null;
});
}
}
String _autoDiscoverySignature(DeviceInfo deviceInfo) {
return [
deviceInfo.manualAddContacts,
deviceInfo.autoAddUsers,
deviceInfo.autoAddRepeaters,
deviceInfo.autoAddRoomServers,
deviceInfo.autoAddSensors,
deviceInfo.autoAddOverwriteOldest,
].join('|');
}
void _handleConnectionProviderChanged() {
if (!mounted || _autoDiscoverySettingsDirty) return;
final deviceInfo = _connectionProvider.deviceInfo;
final nextSignature = _autoDiscoverySignature(deviceInfo);
if (nextSignature == _lastAutoDiscoverySignature) return;
_lastAutoDiscoverySignature = nextSignature;
setState(() {
_syncAutoDiscoveryState(deviceInfo);
});
}
bool _hasAutoAddTargetsEnabled(DeviceInfo deviceInfo) {
return (deviceInfo.autoAddUsers ?? false) ||
(deviceInfo.autoAddRepeaters ?? false) ||
(deviceInfo.autoAddRoomServers ?? false) ||
(deviceInfo.autoAddSensors ?? false);
}
void _syncAutoDiscoveryState(DeviceInfo deviceInfo) {
final hasFetchedAutoAddConfig =
deviceInfo.autoAddUsers != null ||
deviceInfo.autoAddRepeaters != null ||
deviceInfo.autoAddRoomServers != null ||
deviceInfo.autoAddSensors != null ||
deviceInfo.autoAddOverwriteOldest != null;
_autoAddDiscoveredContactsEnabled = hasFetchedAutoAddConfig
? _hasAutoAddTargetsEnabled(deviceInfo)
: !(deviceInfo.manualAddContacts ?? false);
_autoAddUsersEnabled = deviceInfo.autoAddUsers ?? true;
_autoAddRepeatersEnabled = deviceInfo.autoAddRepeaters ?? true;
_autoAddRoomServersEnabled = deviceInfo.autoAddRoomServers ?? true;
_autoAddSensorsEnabled = deviceInfo.autoAddSensors ?? true;
_overwriteOldestAutoAddEnabled = deviceInfo.autoAddOverwriteOldest ?? false;
}
int _telemetryModesForSave(ConnectionProvider connectionProvider) {
final deviceInfo = connectionProvider.deviceInfo;
final telemetryEnabled =
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
return deviceInfo.telemetryModes ?? (telemetryEnabled ? 0x0A : 0x00);
}
int _advertLocationPolicyForSave(ConnectionProvider connectionProvider) {
final deviceInfo = connectionProvider.deviceInfo;
final telemetryEnabled =
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
return deviceInfo.advertLocPolicy ?? (telemetryEnabled ? 1 : 0);
}
Future<void> _savePublicInfo() async {
final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
final validator = ValidationService();
setState(() {
@@ -425,7 +534,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Set telemetry modes to "Allow All" (mode 2 for both base and location)
final telemetryModes = 0x0A; // binary: 00001010 (base=2, location=2)
await connectionProvider.setOtherParams(
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: telemetryModes,
advertLocationPolicy: 1,
);
@@ -436,7 +545,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
// Set telemetry modes to "Deny" (mode 0)
final telemetryModes = 0x00;
await connectionProvider.setOtherParams(
manualAddContacts: deviceInfo.manualAddContacts == true ? 1 : 0,
manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: telemetryModes,
advertLocationPolicy: 0,
);
@@ -537,6 +646,95 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
}
Future<void> _saveAutoDiscoverySettings() async {
final connectionProvider = context.read<ConnectionProvider>();
final autoAddUsers =
_autoAddDiscoveredContactsEnabled && _autoAddUsersEnabled;
final autoAddRepeaters =
_autoAddDiscoveredContactsEnabled && _autoAddRepeatersEnabled;
final autoAddRoomServers =
_autoAddDiscoveredContactsEnabled && _autoAddRoomServersEnabled;
final autoAddSensors =
_autoAddDiscoveredContactsEnabled && _autoAddSensorsEnabled;
final overwriteOldest =
_autoAddDiscoveredContactsEnabled && _overwriteOldestAutoAddEnabled;
setState(() {
_isSavingAutoDiscoverySettings = true;
_autoDiscoverySettingsSaved = false;
_autoDiscoverySettingsError = null;
});
try {
await connectionProvider.setAutoaddConfig(
autoAddUsers: autoAddUsers,
autoAddRepeaters: autoAddRepeaters,
autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors,
overwriteOldest: overwriteOldest,
);
await connectionProvider.setOtherParams(
manualAddContacts: _autoAddFilterModeFlag,
telemetryModes: _telemetryModesForSave(connectionProvider),
advertLocationPolicy: _advertLocationPolicyForSave(connectionProvider),
multiAcks: connectionProvider.deviceInfo.multiAcks ?? 0,
);
await connectionProvider.getAutoaddConfig();
if (mounted) {
setState(() {
_syncAutoDiscoveryState(connectionProvider.deviceInfo);
_lastAutoDiscoverySignature = _autoDiscoverySignature(
connectionProvider.deviceInfo,
);
_isSavingAutoDiscoverySettings = false;
_autoDiscoverySettingsSaved = true;
_autoDiscoverySettingsDirty = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isSavingAutoDiscoverySettings = false;
_autoDiscoverySettingsError = AppLocalizations.of(
context,
)!.failedToSave(e.toString());
});
}
}
}
Future<void> _loadGpsMode() async {
try {
final vars = await _connectionProvider.getCustomVars();
if (!mounted) return;
final gpsValue = vars['gps'];
setState(() {
_gpsEnabled = gpsValue != null ? gpsValue == '1' : null;
});
} catch (_) {
// Device may not support custom vars (old firmware / no GPS hardware)
}
}
Future<void> _setGpsMode(bool enabled) async {
setState(() => _gpsLoading = true);
try {
await _connectionProvider.setCustomVar('gps', enabled ? '1' : '0');
if (!mounted) return;
setState(() {
_gpsEnabled = enabled;
_gpsLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _gpsLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to set GPS mode: $e')),
);
}
}
Future<void> _useCurrentLocation() async {
try {
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
@@ -629,7 +827,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Wipe device data'),
title: Text(AppLocalizations.of(context)!.wipeDeviceData),
content: const Text(
'This will erase all data on the connected device, including contacts, keys, and saved settings. This cannot be undone.',
),
@@ -644,7 +842,7 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Wipe device'),
child: Text(AppLocalizations.of(context)!.wipeDevice),
),
],
),
@@ -687,6 +885,205 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
}
Future<void> _confirmClearAllContacts() async {
final contacts = context
.read<ContactsProvider>()
.contacts
.where((contact) => !contact.isChannel)
.toList();
if (contacts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.noDeviceContactsToClear)),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(AppLocalizations.of(context)!.clearAllContacts),
content: Text(
'This will remove ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the connected device. Channels and radio settings will not be changed.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(AppLocalizations.of(context)!.clearContacts),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
connectionProvider.clearError();
setState(() {
_isClearingContacts = true;
});
try {
var processed = 0;
for (final contact in List<Contact>.from(contacts)) {
await contactsProvider.removeContact(
contact.publicKeyHex,
onRemoveFromDevice: connectionProvider.removeContact,
);
processed++;
await Future.delayed(_bulkDeleteInterItemDelay);
if (processed % _bulkDeleteBatchSize == 0) {
await Future.delayed(_bulkDeleteBatchDelay);
}
}
// Flush the updated local contact set to storage immediately.
await contactsProvider.persistNow();
await Future.delayed(_bulkDeleteFinalSyncDelay);
await connectionProvider.getContacts();
if (connectionProvider.error != null) {
throw Exception(connectionProvider.error!);
}
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
'Cleared ${contacts.length} contact${contacts.length == 1 ? '' : 's'} from the device.',
),
),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('Failed to clear contacts: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} finally {
if (mounted) {
setState(() {
_isClearingContacts = false;
});
}
}
}
Future<void> _confirmClearAllChannels() async {
final channels = context
.read<ChannelsProvider>()
.channels
.where((channel) => !channel.isPublicChannel && channel.name.isNotEmpty)
.toList();
if (channels.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.noCustomChannelsToClear)),
);
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(AppLocalizations.of(context)!.clearAllChannels),
content: Text(
'This will remove ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the connected device. Contacts and radio settings will not be changed.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(AppLocalizations.of(context)!.clearChannels),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final connectionProvider = context.read<ConnectionProvider>();
final channelsProvider = context.read<ChannelsProvider>();
final contactsProvider = context.read<ContactsProvider>();
connectionProvider.clearError();
setState(() {
_isClearingChannels = true;
});
try {
var processed = 0;
for (final channel in List<Channel>.from(channels)) {
await connectionProvider.deleteChannel(channel.index);
processed++;
await Future.delayed(_bulkDeleteInterItemDelay);
if (processed % _bulkDeleteBatchSize == 0) {
await Future.delayed(_bulkDeleteBatchDelay);
}
}
// Force local channel/contact cache cleanup before the device resync.
for (final channel in channels) {
channelsProvider.removeChannel(channel.index);
final publicKeyBytes = Uint8List(32);
publicKeyBytes[0] = 0xFF;
publicKeyBytes[1] = channel.index;
final publicKeyHex = publicKeyBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
await contactsProvider.removeContact(publicKeyHex);
}
await contactsProvider.persistNow();
await Future.delayed(_bulkDeleteFinalSyncDelay);
await connectionProvider.syncChannels();
if (connectionProvider.error != null) {
throw Exception(connectionProvider.error!);
}
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
'Cleared ${channels.length} custom channel${channels.length == 1 ? '' : 's'} from the device.',
),
),
);
} catch (e) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('Failed to clear channels: $e'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} finally {
if (mounted) {
setState(() {
_isClearingChannels = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
@@ -722,23 +1119,23 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
'${_getDeviceTypeString(context, deviceInfo.deviceType)}${deviceInfo.semanticVersion ?? deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown}',
stats: [
_HeroStatData(
label: 'Location',
label: AppLocalizations.of(context)!.location,
value: locationSet ? 'Shared' : 'Hidden',
icon: Icons.my_location_rounded,
emphasized: locationSet,
),
_HeroStatData(
label: 'Frequency',
label: AppLocalizations.of(context)!.frequency,
value: '${_freqController.text} MHz',
icon: Icons.settings_input_antenna_rounded,
),
_HeroStatData(
label: 'Bandwidth',
label: AppLocalizations.of(context)!.bandwidth,
value: _selectedBandwidth,
icon: Icons.width_normal_rounded,
),
_HeroStatData(
label: 'Model',
label: AppLocalizations.of(context)!.model,
value:
deviceInfo.manufacturerModel ??
AppLocalizations.of(context)!.unknown,
@@ -746,10 +1143,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
],
),
const SizedBox(height: 20),
SizedBox(height: 20),
_ConfigSectionCard(
title: 'Storage',
subtitle: 'Available space on this device.',
title: AppLocalizations.of(context)!.storage,
subtitle: AppLocalizations.of(context)!.availableSpaceOnThisDevice,
icon: Icons.storage_rounded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -758,16 +1155,16 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
children: [
Expanded(
child: _StorageStat(
label: 'Used',
label: AppLocalizations.of(context)!.used,
value: _formatStorage(
deviceInfo.storageUsedKb ?? 0,
),
),
),
const SizedBox(width: 12),
SizedBox(width: 12),
Expanded(
child: _StorageStat(
label: 'Total',
label: AppLocalizations.of(context)!.total,
value: deviceInfo.storageTotalKb != null
? _formatStorage(deviceInfo.storageTotalKb!)
: AppLocalizations.of(context)!.unknown,
@@ -780,10 +1177,168 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
],
),
),
const SizedBox(height: 20),
SizedBox(height: 20),
_ConfigSectionCard(
title: AppLocalizations.of(context)!.autoDiscovery,
subtitle:
'Control how the radio auto-adds discovered nodes to its contacts table.',
icon: Icons.person_search_rounded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SettingHighlightCard(
icon: _autoAddDiscoveredContactsEnabled
? Icons.person_add_alt_1
: Icons.person_add_disabled,
title: AppLocalizations.of(context)!.enableAutomaticAdding,
description:
'Turn this off to keep discoveries manual-only on the radio.',
accentColor: _autoAddDiscoveredContactsEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddDiscoveredContactsEnabled,
onChanged: (value) {
setState(() {
_autoAddDiscoveredContactsEnabled = value;
_markAutoDiscoverySettingsDirty();
});
},
),
),
SizedBox(height: 18),
_SettingHighlightCard(
icon: Icons.person_outline_rounded,
title: AppLocalizations.of(context)!.autoaddUsers,
description:
'Automatically store discovered user/chat nodes.',
accentColor: _autoAddUsersEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddUsersEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddUsersEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.router_outlined,
title: AppLocalizations.of(context)!.autoaddRepeaters,
description:
'Automatically store discovered repeater nodes.',
accentColor: _autoAddRepeatersEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddRepeatersEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddRepeatersEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.meeting_room_outlined,
title: AppLocalizations.of(context)!.autoaddRoomServers,
description:
'Automatically store discovered room/server nodes.',
accentColor: _autoAddRoomServersEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddRoomServersEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddRoomServersEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.sensors_outlined,
title: AppLocalizations.of(context)!.autoaddSensors,
description:
'Automatically store discovered sensor nodes.',
accentColor: _autoAddSensorsEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _autoAddSensorsEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_autoAddSensorsEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
SizedBox(height: 14),
_SettingHighlightCard(
icon: Icons.history_toggle_off_rounded,
title: AppLocalizations.of(context)!.overwriteOldestWhenFull,
description:
'Allow the radio to replace the oldest contact when storage is full.',
accentColor: _overwriteOldestAutoAddEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: Switch(
value: _overwriteOldestAutoAddEnabled,
onChanged: _autoAddDiscoveredContactsEnabled
? (value) {
setState(() {
_overwriteOldestAutoAddEnabled = value;
_markAutoDiscoverySettingsDirty();
});
}
: null,
),
),
const SizedBox(height: 18),
if (_autoDiscoverySettingsError != null) ...[
Text(
_autoDiscoverySettingsError!,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error,
),
),
const SizedBox(height: 10),
],
SizedBox(
width: double.infinity,
child: _SaveActionButton(
onPressed: _isSavingAutoDiscoverySettings
? null
: _saveAutoDiscoverySettings,
isSaving: _isSavingAutoDiscoverySettings,
isSaved: _autoDiscoverySettingsSaved,
label: AppLocalizations.of(context)!.saveDiscoverySettings,
),
),
],
),
),
SizedBox(height: 20),
_ConfigSectionCard(
title: AppLocalizations.of(context)!.publicInfo,
subtitle: 'Choose the name and location this device shares.',
subtitle: AppLocalizations.of(context)!.chooseTheNameAndLocationThisDeviceShares,
icon: Icons.public_rounded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -810,6 +1365,32 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
},
),
),
if (_gpsEnabled != null) ...[
const SizedBox(height: 12),
_SettingHighlightCard(
icon: _gpsEnabled!
? Icons.gps_fixed
: Icons.gps_off,
title: AppLocalizations.of(context)!.gpsModule,
description:
'Enable or disable the onboard GPS hardware.',
accentColor: _gpsEnabled!
? colorScheme.primary
: colorScheme.onSurfaceVariant,
trailing: _gpsLoading
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Switch(
value: _gpsEnabled!,
onChanged: _setGpsMode,
),
),
],
const SizedBox(height: 18),
TextField(
controller: _nameController,
@@ -902,16 +1483,16 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
onPressed: _isSavingPublicInfo ? null : _savePublicInfo,
isSaving: _isSavingPublicInfo,
isSaved: _publicInfoSaved,
label: 'Save public info',
label: AppLocalizations.of(context)!.savePublicInfo,
),
),
],
),
),
const SizedBox(height: 20),
SizedBox(height: 20),
_ConfigSectionCard(
title: AppLocalizations.of(context)!.radioSettings,
subtitle: 'Choose a preset or fine-tune custom radio settings.',
subtitle: AppLocalizations.of(context)!.chooseAPresetOrFinetuneCustomRadioSettings,
icon: Icons.settings_input_antenna_rounded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -931,9 +1512,9 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
isExpanded: true,
items: [
const DropdownMenuItem<_RadioPreset?>(
DropdownMenuItem<_RadioPreset?>(
value: null,
child: Text('Custom'),
child: Text(AppLocalizations.of(context)!.custom),
),
..._radioPresets.map(
(preset) => DropdownMenuItem<_RadioPreset?>(
@@ -1132,10 +1713,10 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
),
if (deviceInfo.clientRepeat != null) ...[
const SizedBox(height: 16),
SizedBox(height: 16),
_SettingHighlightCard(
icon: Icons.repeat_rounded,
title: 'Repeat nearby traffic',
title: AppLocalizations.of(context)!.repeatNearbyTraffic,
description:
deviceInfo.allowedRepeatFreqRanges != null &&
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
@@ -1172,16 +1753,16 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
: _saveRadioSettings,
isSaving: _isSavingRadioSettings,
isSaved: _radioSettingsSaved,
label: 'Save radio settings',
label: AppLocalizations.of(context)!.saveRadioSettings,
),
),
],
),
),
const SizedBox(height: 20),
SizedBox(height: 20),
_ConfigSectionCard(
title: 'Danger zone',
subtitle: 'Destructive device actions.',
title: AppLocalizations.of(context)!.dangerZone,
subtitle: AppLocalizations.of(context)!.destructiveDeviceActions,
icon: Icons.warning_amber_rounded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -1230,17 +1811,67 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmClearAllContacts,
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
minimumSize: const Size.fromHeight(52),
),
icon: _isClearingContacts
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(Icons.people_alt_outlined),
label: Text(AppLocalizations.of(context)!.clearAllContacts),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmClearAllChannels,
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
minimumSize: const Size.fromHeight(52),
),
icon: _isClearingChannels
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(Icons.forum_outlined),
label: Text(AppLocalizations.of(context)!.clearAllChannels),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _confirmFactoryReset,
onPressed: _isClearingContacts || _isClearingChannels
? null
: _confirmFactoryReset,
style: FilledButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
minimumSize: const Size.fromHeight(52),
),
icon: const Icon(Icons.delete_forever_rounded),
label: const Text('Wipe device data'),
icon: Icon(Icons.delete_forever_rounded),
label: Text(AppLocalizations.of(context)!.wipeDeviceData),
),
),
],

View File

@@ -0,0 +1,732 @@
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: Text(AppLocalizations.of(context)!.clearDiscoveries),
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: Text(AppLocalizations.of(context)!.clearAllLabel),
),
],
),
);
if (confirmed != true || !mounted) {
return;
}
await context.read<ContactsProvider>().clearPendingAdverts();
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries)),
);
}
Future<void> _discoverNodeType(int advertType) async {
if (_runningDiscoveryTypes.contains(advertType)) return;
setState(() {
_runningDiscoveryTypes.add(advertType);
});
try {
await context.read<ConnectionProvider>().discoverNodeType(
advertType: advertType,
);
if (!mounted) return;
final label = switch (advertType) {
_repeaterAdvertType => 'Repeater discovery sent',
_sensorAdvertType => 'Sensor discovery sent',
_ => 'Discovery sent',
};
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(label)));
} finally {
if (mounted) {
setState(() {
_runningDiscoveryTypes.remove(advertType);
});
}
}
}
Future<void> _resolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
setState(() {
_resolvingAdvertKeys.add(keyHex);
});
try {
final connectionProvider = context.read<ConnectionProvider>();
final contactsProvider = context.read<ContactsProvider>();
final canAddDirectly = _canAddPendingAdvertDirectly(advert);
if (canAddDirectly) {
final added = await _addPendingAdvertToRadio(
advert,
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
);
if (!added) {
return;
}
} else {
await connectionProvider.getContact(advert.publicKey);
if (connectionProvider.error == 'Not found') {
connectionProvider.clearError();
final added = await _addPendingAdvertToRadio(
advert,
connectionProvider: connectionProvider,
contactsProvider: contactsProvider,
);
if (!added) {
return;
}
}
}
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
await connectionProvider.requestTelemetry(advert.publicKey);
}
} finally {
if (mounted) {
setState(() {
_resolvingAdvertKeys.remove(keyHex);
});
}
}
}
Future<void> _resolveAll(List<PendingAdvert> adverts) async {
if (_isResolvingAll || adverts.isEmpty) return;
setState(() {
_isResolvingAll = true;
});
try {
final contactsProvider = context.read<ContactsProvider>();
for (final advert in adverts) {
if (!mounted) break;
// Skip already-resolved contacts
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
continue;
}
await _resolveAdvert(advert);
}
} finally {
if (mounted) {
setState(() {
_isResolvingAll = false;
});
}
}
}
String _formatRelativeTime(BuildContext context, DateTime when) {
final l10n = AppLocalizations.of(context)!;
final diff = DateTime.now().difference(when);
if (diff.inMinutes < 1) return l10n.justNow;
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
return l10n.daysAgo(diff.inDays);
}
Contact _contactFromPendingAdvert(PendingAdvert advert) {
final lastAdvert =
advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000);
final advName = advert.advName?.trim().isNotEmpty == true
? advert.advName!.trim()
: advert.shortDisplayKey;
return Contact(
publicKey: Uint8List.fromList(advert.publicKey),
type: ContactType.fromValue(advert.typeValue ?? 0),
flags: advert.flags ?? 0,
outPathLen: advert.signedEncodedPathLen ?? -1,
outPath: advert.paddedPathBytes == null
? Uint8List(64)
: Uint8List.fromList(advert.paddedPathBytes!),
advName: advName,
lastAdvert: lastAdvert,
advLat: advert.advLat ?? 0,
advLon: advert.advLon ?? 0,
lastMod: lastAdvert,
);
}
bool _canAddPendingAdvertDirectly(PendingAdvert advert) {
return advert.publicKey.length == 32 &&
advert.typeValue != null &&
advert.typeValue != 0;
}
Future<bool> _addPendingAdvertToRadio(
PendingAdvert advert, {
required ConnectionProvider connectionProvider,
required ContactsProvider contactsProvider,
}) async {
final contact = _contactFromPendingAdvert(advert);
await connectionProvider.addOrUpdateContact(contact);
final addError = connectionProvider.error;
if (addError != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add contact: $addError')),
);
}
return false;
}
contactsProvider.addOrUpdateContact(
contact,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
return true;
}
String _displayNameForAdvert(
PendingAdvert advert,
ContactsProvider contactsProvider,
List<MeshMapNode> cachedNodes,
) {
final advertisedName = advert.advName?.trim();
if (advertisedName != null && advertisedName.isNotEmpty) {
return advertisedName;
}
Contact? existingMatch;
for (final contact in contactsProvider.contacts) {
if (contact.publicKeyHex == advert.publicKeyHex) {
existingMatch = contact;
break;
}
}
if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) {
return existingMatch.displayName;
}
MeshMapNode? cachedMatch;
for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
cachedMatch = node;
break;
}
}
if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) {
return cachedMatch.name;
}
return advert.shortDisplayKey;
}
IconData _iconForAdvert(PendingAdvert advert) {
return switch (advert.typeValue) {
_repeaterAdvertType => Icons.router_outlined,
_sensorAdvertType => Icons.sensors_outlined,
_ => Icons.campaign_outlined,
};
}
String? _typeLabelForAdvert(PendingAdvert advert) {
return switch (advert.typeValue) {
_repeaterAdvertType => 'Repeater',
_sensorAdvertType => 'Sensor',
3 => 'Room',
1 => 'Chat',
_ => null,
};
}
String? _resolvedTypeLabelForAdvert(
PendingAdvert advert,
List<MeshMapNode> cachedNodes,
) {
final directType = _typeLabelForAdvert(advert);
if (directType != null) {
return directType;
}
for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
return switch (node.type) {
1 => 'Repeater',
4 => 'Sensor',
3 => 'Room',
2 => 'Chat',
_ => null,
};
}
}
return null;
}
Widget _buildAdvertTitle(
BuildContext context, {
required String displayName,
String? subtitle,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (subtitle != null && subtitle.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
],
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.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),
SizedBox(width: 12),
Text(l10n.discoverRepeaters),
],
),
),
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),
SizedBox(width: 12),
Text(l10n.discoverSensors),
],
),
),
],
);
},
),
],
),
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>[];
// Track which pending adverts are already in the contacts list
final resolvedKeySet = <String>{};
for (final advert in pendingAdverts) {
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
resolvedKeySet.add(advert.publicKeyHex);
}
}
final totalCount = pendingAdverts.length;
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(
'Discovered nodes ($totalCount)',
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: Text(l10n.resolveAll),
),
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: pendingAdverts.isNotEmpty
? _clearAllDiscoveries
: null,
icon: Icon(Icons.clear_all_rounded),
label: Text(l10n.clearAllLabel),
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
if (totalCount == 0)
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 discovered nodes',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Use the menu to discover repeaters and sensors on the mesh.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
...pendingAdverts.map((advert) =>
_buildPendingAdvertCard(
context,
advert: advert,
contactsProvider: contactsProvider,
isConnected: isConnected,
isResolved: resolvedKeySet.contains(advert.publicKeyHex),
cachedNodes: cachedNodes,
l10n: l10n,
),
),
],
);
},
),
),
);
}
Widget _buildPendingAdvertCard(
BuildContext context, {
required PendingAdvert advert,
required ContactsProvider contactsProvider,
required bool isConnected,
required bool isResolved,
required List<MeshMapNode> cachedNodes,
required AppLocalizations l10n,
}) {
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),
if (isResolved)
Icon(
Icons.check_circle,
color: Theme.of(context).colorScheme.primary,
size: 24,
)
else if (isResolving)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
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

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -12,6 +14,7 @@ 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';
@@ -19,6 +22,7 @@ import 'settings_screen.dart';
import 'device_config_screen.dart';
import 'packet_log_screen.dart';
import 'live_traffic_screen.dart';
import 'profiles_screen.dart';
import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart';
@@ -27,6 +31,10 @@ import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/profile_device_key_resolver.dart';
import '../services/profile_manager.dart';
import '../services/profile_workspace_coordinator.dart';
import '../services/profiles_feature_service.dart';
enum _HomeTab { messages, contacts, sensors, map }
@@ -56,6 +64,7 @@ class _HomeScreenState extends State<HomeScreen>
with TickerProviderStateMixin, WidgetsBindingObserver {
late TabController _tabController;
late final AppProvider _appProvider;
late final ConnectionProvider _connectionProvider;
int _currentIndex = 0;
bool _isMapFullscreen = false;
bool _showRxTxIndicators = true;
@@ -64,6 +73,7 @@ class _HomeScreenState extends State<HomeScreen>
bool _isContactsEnabled = true;
bool _isSensorsEnabled = false;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _lastProfileDeviceKey;
List<_HomeTab> get _enabledTabs {
return [
@@ -87,6 +97,8 @@ class _HomeScreenState extends State<HomeScreen>
super.initState();
WidgetsBinding.instance.addObserver(this);
_appProvider = context.read<AppProvider>();
_connectionProvider = context.read<ConnectionProvider>();
_connectionProvider.addListener(_handleConnectionProviderChanged);
_isMapEnabled = _appProvider.isMapEnabled;
_isContactsEnabled = _appProvider.isContactsEnabled;
_isSensorsEnabled = _appProvider.isSensorsEnabled;
@@ -104,6 +116,10 @@ class _HomeScreenState extends State<HomeScreen>
_showPermissionDialog();
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleConnectionProviderChanged();
});
}
void _initTabController() {
@@ -219,6 +235,36 @@ class _HomeScreenState extends State<HomeScreen>
);
}
void _handleConnectionProviderChanged() {
final deviceKey = ProfileDeviceKeyResolver.resolve(
deviceInfo: _connectionProvider.deviceInfo,
connectionMode: _connectionProvider.connectionMode,
);
if (_lastProfileDeviceKey == deviceKey) {
return;
}
_lastProfileDeviceKey = deviceKey;
if (deviceKey == null || !mounted) {
return;
}
unawaited(
context
.read<ProfileWorkspaceCoordinator>()
.syncActiveProfileForCurrentDevice(),
);
}
@override
void dispose() {
_connectionProvider.removeListener(_handleConnectionProviderChanged);
WidgetsBinding.instance.removeObserver(this);
_appProvider.setFastLocationUiActive(false);
_appProvider.removeListener(_handleAppProviderChanged);
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
}
void _openLiveTraffic(ConnectionProvider provider) {
openLiveTrafficScreen(context, provider);
}
@@ -236,7 +282,11 @@ class _HomeScreenState extends State<HomeScreen>
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true;
_showRxTxIndicators =
prefs.getBool(
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
) ??
true;
});
}
}
@@ -249,16 +299,6 @@ class _HomeScreenState extends State<HomeScreen>
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_appProvider.setFastLocationUiActive(false);
_appProvider.removeListener(_handleAppProviderChanged);
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
}
void _showPermissionDialog() {
if (!mounted) return;
@@ -354,7 +394,7 @@ class _HomeScreenState extends State<HomeScreen>
),
),
title: Text(l10n.flood),
subtitle: const Text('Relay through repeaters across the mesh'),
subtitle: Text(l10n.relayThroughRepeatersAcrossTheMesh),
trailing: const Icon(Icons.chevron_right_rounded),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
@@ -380,7 +420,7 @@ class _HomeScreenState extends State<HomeScreen>
),
),
title: Text(l10n.direct),
subtitle: const Text('Nearby only, without repeater flooding'),
subtitle: Text(l10n.nearbyOnlyWithoutRepeaterFlooding),
trailing: const Icon(Icons.chevron_right_rounded),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
@@ -394,42 +434,24 @@ class _HomeScreenState extends State<HomeScreen>
);
}
Widget _buildActivityBadge({
required String label,
required int count,
required bool isActive,
required Color activeColor,
bool compact = false,
}) {
final color = isActive ? activeColor : Colors.grey;
return Container(
padding: EdgeInsets.symmetric(
horizontal: compact ? 7 : 8,
vertical: compact ? 4 : 5,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Widget _buildMiniSignalBars({required int activeBars, required Color color}) {
final inactive = Colors.grey.withValues(alpha: 0.3);
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < 5; i++) ...[
if (i > 0) const SizedBox(width: 1.5),
Container(
width: compact ? 6 : 7,
height: compact ? 6 : 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
SizedBox(width: compact ? 5 : 6),
Text(
'$label:$count',
style: TextStyle(
fontSize: compact ? 10 : 11,
fontWeight: FontWeight.w600,
color: color,
width: 2.5,
height: 4.0 + (i * 2),
decoration: BoxDecoration(
color: i < activeBars ? color : inactive,
borderRadius: BorderRadius.circular(1.5),
),
),
],
),
],
);
}
@@ -589,6 +611,9 @@ class _HomeScreenState extends State<HomeScreen>
}
context.watch<AppProvider>();
final activeProfileId = context.select<ProfileManager, String>(
(manager) => manager.activeProfileId,
);
final enabledTabs = _enabledTabs;
final isMapTabActive = _currentTab == _HomeTab.map;
@@ -613,7 +638,7 @@ class _HomeScreenState extends State<HomeScreen>
onPressed: () async {
await provider.disconnect();
},
icon: const Icon(Icons.power_settings_new),
icon: Icon(Icons.power_settings_new),
tooltip: AppLocalizations.of(context)!.disconnect,
color: Colors.red.shade700,
);
@@ -625,6 +650,9 @@ class _HomeScreenState extends State<HomeScreen>
icon: const Icon(Icons.more_vert),
itemBuilder: (context) {
final items = <PopupMenuEntry<void>>[];
final profilesEnabled = context
.read<ProfileManager>()
.profilesEnabled;
if (_isDeveloperModeEnabled) {
items.add(
@@ -632,8 +660,8 @@ class _HomeScreenState extends State<HomeScreen>
child: Row(
children: [
const Icon(Icons.radar),
const SizedBox(width: 8),
const Text('Spectrum Scan'),
SizedBox(width: 8),
Text(AppLocalizations.of(context)!.spectrumScan),
],
),
onTap: () {
@@ -654,11 +682,11 @@ class _HomeScreenState extends State<HomeScreen>
items.add(
PopupMenuItem(
child: const Row(
child: Row(
children: [
Icon(Icons.radar_outlined),
SizedBox(width: 8),
Text('Live Traffic'),
const Icon(Icons.radar_outlined),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.liveTraffic),
],
),
onTap: () {
@@ -689,11 +717,11 @@ class _HomeScreenState extends State<HomeScreen>
items.add(
PopupMenuItem(
child: const Row(
child: Row(
children: [
Icon(Icons.router_outlined),
SizedBox(width: 8),
Text('Repeaters Map'),
const Icon(Icons.router_outlined),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.repeatersMap),
],
),
onTap: () {
@@ -716,8 +744,41 @@ class _HomeScreenState extends State<HomeScreen>
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
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(
children: [
const Icon(Icons.settings),
SizedBox(width: 8),
Text(AppLocalizations.of(context)!.settings),
],
),
@@ -742,6 +803,31 @@ class _HomeScreenState extends State<HomeScreen>
),
);
if (profilesEnabled) {
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.layers_outlined),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.profiles),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const ProfilesScreen(),
),
);
});
},
),
);
}
return items;
},
),
@@ -768,9 +854,14 @@ class _HomeScreenState extends State<HomeScreen>
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
);
case _HomeTab.sensors:
return const SensorsTab();
return SensorsTab(
isActive:
_currentTab == _HomeTab.sensors &&
_lifecycleState == AppLifecycleState.resumed,
);
case _HomeTab.map:
return MapTab(
key: ValueKey<String>('map:$activeProfileId'),
onFullscreenChanged: (isFullscreen) {
setState(() {
_isMapFullscreen = isFullscreen;
@@ -827,7 +918,7 @@ class _HomeScreenState extends State<HomeScreen>
);
case _HomeTab.map:
return Tab(
icon: const Icon(Icons.map),
icon: Icon(Icons.map),
text: AppLocalizations.of(context)!.map,
);
case _HomeTab.sensors:
@@ -852,19 +943,35 @@ class _HomeScreenState extends State<HomeScreen>
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
if (!isConnected) {
// Disconnected state: show connect button
final buttonLabel = provider.isReconnecting
? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}'
: AppLocalizations.of(context)!.connect;
return Row(
children: [
Expanded(
child: Text(
AppLocalizations.of(context)!.appTitle,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
AppLocalizations.of(context)!.appTitle,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
Text(
provider.isReconnecting
? 'Restoring previous link'
: 'No device connected',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
ElevatedButton.icon(
FilledButton.icon(
onPressed: provider.isReconnecting
? null
: () => _showConnectionDialog(context),
@@ -875,22 +982,19 @@ class _HomeScreenState extends State<HomeScreen>
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.black54,
Colors.white70,
),
),
)
: const Icon(Icons.bluetooth, size: 18),
label: Text(
provider.isReconnecting
? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}'
: AppLocalizations.of(context)!.connect,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
elevation: 0,
: const Icon(Icons.add_link_rounded, size: 18),
label: Text(buttonLabel),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(16),
),
),
),
@@ -898,7 +1002,7 @@ class _HomeScreenState extends State<HomeScreen>
const SizedBox(width: 8),
IconButton(
onPressed: () => provider.cancelReconnection(),
icon: const Icon(Icons.close, size: 20),
icon: Icon(Icons.close, size: 20),
tooltip: AppLocalizations.of(context)!.cancelReconnection,
style: IconButton.styleFrom(
backgroundColor: Colors.red.shade700,
@@ -970,17 +1074,12 @@ class _HomeScreenState extends State<HomeScreen>
if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4),
SizedBox(
width: 28,
child: Text(
'${deviceInfo.signalRssi}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: signalColor,
),
maxLines: 1,
),
_buildMiniSignalBars(
activeBars:
BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!,
),
color: signalColor,
),
],
if (deviceInfo.batteryPercent != null) ...[
@@ -995,20 +1094,16 @@ class _HomeScreenState extends State<HomeScreen>
deviceInfo.batteryPercent!,
),
),
const SizedBox(width: 4),
SizedBox(
width: 30,
child: Text(
'${deviceInfo.batteryPercent!.round()}%',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
const SizedBox(width: 2),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
],
@@ -1109,48 +1204,13 @@ class _HomeScreenState extends State<HomeScreen>
),
);
},
child: isTight
? _buildCompactActivityIndicator(
rxActive: provider.rxActivity,
txActive: provider.txActivity,
)
: Container(
constraints: const BoxConstraints(minHeight: 48),
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(22),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildActivityBadge(
label: 'RX',
count: provider.rxPacketCount,
isActive: provider.rxActivity,
activeColor: Colors.green,
compact: true,
),
const SizedBox(height: 4),
_buildActivityBadge(
label: 'TX',
count: provider.txPacketCount,
isActive: provider.txActivity,
activeColor: Colors.blue,
compact: true,
),
],
),
),
child: _buildCompactActivityIndicator(
rxActive: provider.rxActivity,
txActive: provider.txActivity,
),
)
else
SizedBox(width: isTight ? 24 : 74),
const SizedBox(width: 24),
],
);
},

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
@@ -11,7 +12,10 @@ import '../services/live_traffic_summary.dart';
import '../services/location_tracking_service.dart';
import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart';
import '../widgets/compact_signal_indicator.dart';
import '../widgets/messages/message_trace_sheet.dart';
import 'packet_log_screen.dart';
import '../l10n/app_localizations.dart';
T? _maybeProvider<T>(BuildContext context) {
try {
@@ -134,16 +138,17 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
final routeHashCounts = _RouteHashCounts.fromContacts(
contactsProvider?.contacts ?? const <Contact>[],
);
final packetTypes = unfilteredSnapshot.visibleEntries
.map((entry) => entry.payloadLabel)
.toSet()
.toList()
..sort();
final packetTypes =
unfilteredSnapshot.visibleEntries
.map((entry) => entry.payloadLabel)
.toSet()
.toList()
..sort();
final filteredEntries = snapshot.visibleEntries;
return Scaffold(
appBar: AppBar(
title: const Text('Live Traffic'),
title: Text(AppLocalizations.of(context)!.liveTraffic),
actions: [
if (widget.openPacketLogs != null)
IconButton(
@@ -202,7 +207,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
Icon(
Icons.radar_rounded,
size: 64,
color: theme.colorScheme.primary.withValues(alpha: 0.45),
color: theme.colorScheme.primary.withValues(
alpha: 0.45,
),
),
const SizedBox(height: 16),
const Text(
@@ -218,7 +225,9 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
? 'This view only shows in-memory traffic while it is active.'
: 'Try a different packet type or switch back to All.',
textAlign: TextAlign.center,
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
@@ -319,17 +328,17 @@ class _SummaryPanel extends StatelessWidget {
runSpacing: 6,
children: [
_SummaryBadge(
label: 'Mesh',
label: AppLocalizations.of(context)!.mesh,
value: busynessLabel,
color: busynessColor,
),
_SummaryBadge(
label: 'Rate',
label: AppLocalizations.of(context)!.rate,
value: '${snapshot.packetsPerMinute} pkt/min',
color: Theme.of(context).colorScheme.primary,
),
_SummaryBadge(
label: 'Window',
label: AppLocalizations.of(context)!.window,
value: _windowLabel(snapshot.windowDuration),
color: Theme.of(context).colorScheme.secondary,
onTap: onWindowTap,
@@ -342,14 +351,14 @@ class _SummaryPanel extends StatelessWidget {
runSpacing: 10,
children: [
_MetricTile(
label: 'RX packets',
label: AppLocalizations.of(context)!.rxPackets,
value: '${snapshot.rxCount}',
subtitle: totalRxCount == null
? _windowSummaryLabel(snapshot.windowDuration)
: 'Device total $totalRxCount',
),
_MetricTile(
label: 'RSSI',
label: AppLocalizations.of(context)!.rssi,
value: snapshot.latestRssiDbm == null
? 'No RX data'
: '${snapshot.latestRssiDbm} dBm',
@@ -358,7 +367,7 @@ class _SummaryPanel extends StatelessWidget {
: 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm',
),
_MetricTile(
label: 'SNR',
label: AppLocalizations.of(context)!.snr,
value: snapshot.latestSnrDb == null
? 'No RX data'
: '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB',
@@ -367,7 +376,7 @@ class _SummaryPanel extends StatelessWidget {
: 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB',
),
_MetricTile(
label: 'Multi-hop',
label: AppLocalizations.of(context)!.multihop,
value: '${snapshot.multiHopCount}',
subtitle: routeHashCounts.summaryLabel,
footer: snapshot.avgHopCount == null
@@ -447,7 +456,7 @@ class _PacketTypeFilterBar extends StatelessWidget {
child: Row(
children: [
_FilterChip(
label: 'All',
label: AppLocalizations.of(context)!.all,
selected: selectedType == null,
onTap: () => onSelected(null),
),
@@ -488,7 +497,9 @@ class _FilterChip extends StatelessWidget {
decoration: BoxDecoration(
color: color.withValues(alpha: selected ? 0.14 : 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: selected ? 0.45 : 0.22)),
border: Border.all(
color: color.withValues(alpha: selected ? 0.45 : 0.22),
),
),
child: Text(
label,
@@ -641,103 +652,103 @@ class _LiveTrafficCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx;
final accent = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
final routePreview = _resolvedRoutePreview(context, entry);
final originDistance = _originDistanceLabel(context, entry);
final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry);
final signalMetric = SignalMetric.fromRxInfo(rxInfo);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showPacketBytesSheet(context, log.rawData),
onLongPress: () => _showTraceSheet(context, entry),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: accent.withValues(alpha: 0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: accent.withValues(alpha: 0.25)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
),
child: Text(
isRx ? 'RX' : 'TX',
style: TextStyle(
color: accent,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.payloadLabel,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999),
),
child: Text(
isRx ? 'RX' : 'TX',
style: TextStyle(
color: accent,
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
if (entry.payloadMeaning != null)
Text(
entry.payloadMeaning!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
packetDetails.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
if (entry.payloadMeaning != null)
Text(
entry.payloadMeaning!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
],
),
),
if (signalMetric != null) ...[
const SizedBox(width: 12),
CompactSignalIndicator(metric: signalMetric),
] else
Text(
_timeAgo(log.timestamp, now),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
],
),
Text(
_timeAgo(log.timestamp, now),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 10),
_PacketInfoLine(
text:
'${_formatClock(log.timestamp)} • Size: ${log.rawData.length} bytes',
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_PacketMetaChip(
label: '${log.rawData.length} bytes',
onTap: () => _showPacketBytesSheet(context, log.rawData),
),
if (rxInfo?.rssiDbm != null)
_PacketMetaChip(label: 'RSSI ${rxInfo!.rssiDbm} dBm'),
if (rxInfo?.snrDb != null)
_PacketMetaChip(
label: 'SNR ${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
),
if (entry.hopCount != null)
_PacketMetaChip(label: '${entry.hopCount} hops'),
if (entry.isMultiHop)
const _PacketMetaChip(label: 'MULTI-HOP', emphasized: true),
_PacketInfoLine(text: 'Hash: ${packetDetails.packetHashHex}'),
if (packetDetails.pathLine != null)
_PacketInfoLine(text: packetDetails.pathLine!),
if (packetDetails.pathHashLine != null)
_PacketInfoLine(text: packetDetails.pathHashLine!),
if (packetDetails.endpointLine != null)
_PacketInfoLine(text: packetDetails.endpointLine!),
if (originDistance != null)
_PacketMetaChip(label: 'Origin $originDistance'),
_PacketInfoLine(text: 'Origin: $originDistance'),
],
),
const SizedBox(height: 10),
Text(
routePreview,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
@@ -748,36 +759,11 @@ class _LiveTrafficCard extends StatelessWidget {
return '${diff.inMinutes}m ago';
}
static String _resolvedRoutePreview(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.hopHashes.isEmpty) {
return entry.routePreview;
}
final contactsProvider = _maybeProvider<ContactsProvider>(context);
final connectionProvider = _maybeProvider<ConnectionProvider>(context);
if (contactsProvider == null && connectionProvider == null) {
return entry.routePreview;
}
final ownLatLng = _ownLatLng(connectionProvider);
final resolvedLabels = route.hopHashes.map((hashHex) {
final resolved = LogRxRouteDecoder.resolveHash(
hashHex,
contacts: contactsProvider?.contacts ?? const <Contact>[],
ownPublicKey: connectionProvider?.deviceInfo.publicKey,
ownName:
connectionProvider?.deviceInfo.selfName ??
connectionProvider?.deviceInfo.displayName,
ownLatitude: ownLatLng?.latitude,
ownLongitude: ownLatLng?.longitude,
);
return _compactNodeLabel(resolved);
}).toList();
return resolvedLabels.join(' -> ');
static String _formatClock(DateTime timestamp) {
final hour = timestamp.hour.toString().padLeft(2, '0');
final minute = timestamp.minute.toString().padLeft(2, '0');
final second = timestamp.second.toString().padLeft(2, '0');
return '$hour:$minute:$second';
}
static String? _originDistanceLabel(
@@ -856,11 +842,16 @@ class _LiveTrafficCard extends StatelessWidget {
final hex = chunk
.map((byte) => byte.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(' ');
hexLines.add('${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex');
hexLines.add(
'${offset.toRadixString(16).padLeft(4, '0').toUpperCase()}: $hex',
);
}
final ascii = data
.map((byte) => (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.')
.map(
(byte) =>
(byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.',
)
.join();
return showModalBottomSheet<void>(
@@ -886,10 +877,7 @@ class _LiveTrafficCard extends StatelessWidget {
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 16),
Text(
'Hex',
style: Theme.of(context).textTheme.titleSmall,
),
Text('Hex', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Container(
width: double.infinity,
@@ -909,10 +897,7 @@ class _LiveTrafficCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
Text(
'ASCII',
style: Theme.of(context).textTheme.titleSmall,
),
Text('ASCII', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Container(
width: double.infinity,
@@ -940,17 +925,187 @@ class _LiveTrafficCard extends StatelessWidget {
);
}
static String _compactNodeLabel(ResolvedNodeHash node) {
if (node.isOwnNode) {
return node.label;
static Future<void> _showTraceSheet(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.pathBytes.isEmpty) {
return Future.value();
}
if (node.matchCount > 0) {
return node.label;
}
return node.hexLabel;
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => MessageTraceSheet.packetPath(
packetPath: route.pathBytes,
descriptionOverride:
'Relay path from packet path bytes (${route.hopHashes.length} hop${route.hopHashes.length == 1 ? '' : 's'})',
noRelayMatchTextOverride:
'No named nodes could be matched for this packet path.',
),
);
}
}
class _LiveTrafficPacketDetails {
final String title;
final String packetHashHex;
final String? pathLine;
final String? pathHashLine;
final String? endpointLine;
const _LiveTrafficPacketDetails({
required this.title,
required this.packetHashHex,
required this.pathLine,
required this.pathHashLine,
required this.endpointLine,
});
factory _LiveTrafficPacketDetails.fromEntry(LiveTrafficEntry entry) {
final route = entry.route;
final payloadType = route?.payloadType;
final parsedPayload = _ParsedTrafficPayload.tryParse(
entry.log.rawData,
route,
);
final title = switch (payloadType) {
0x00 => 'FLOOD REQUEST',
0x01 => 'FLOOD RESPONSE',
0x02 => 'FLOOD TEXT',
0x03 => 'FLOOD ACK',
0x04 => 'FLOOD ADVERTISEMENT',
0x05 => 'FLOOD GROUP_TEXT',
0x06 => 'FLOOD GROUP_DATA',
0x07 => 'FLOOD ANON_REQUEST',
0x08 => 'FLOOD RETURNED_PATH',
0x09 => 'FLOOD TRACE_PATH',
0x0A => 'FLOOD MULTIPART',
0x0B => 'FLOOD CONTROL',
_ => entry.payloadLabel.toUpperCase(),
};
final hopHashes = route?.hopHashes ?? const <String>[];
final pathLine = hopHashes.isEmpty
? null
: 'Path: ${hopHashes.length} hop${hopHashes.length == 1 ? '' : 's'} [${hopHashes.join(',')}]';
final pathHashLine = route == null
? null
: 'Path Hashes: ${route.hashSize}-byte per hop';
return _LiveTrafficPacketDetails(
title: title,
packetHashHex: _packetHash(entry.log.rawData),
pathLine: pathLine,
pathHashLine: pathHashLine,
endpointLine: parsedPayload?.endpointLine,
);
}
static String _packetHash(List<int> bytes) {
final digest = md5.convert(bytes).bytes;
return digest
.take(8)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
}
}
class _ParsedTrafficPayload {
final String? endpointLine;
const _ParsedTrafficPayload({this.endpointLine});
static _ParsedTrafficPayload? tryParse(
List<int> rawData,
DecodedLogRxRoute? route,
) {
if (rawData.length < 5 ||
rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
return null;
}
final rawPacketData = rawData.sublist(3);
if (rawPacketData.length < 2) return null;
final header = rawPacketData[0];
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
var index = 1;
if (routeType == 0x00 || routeType == 0x03) {
if (rawPacketData.length < index + 5) return null;
index += 4;
}
if (rawPacketData.length <= index) return null;
final pathDescriptor = rawPacketData[index++];
final pathByteLen =
LogRxRouteDecoder.descriptorByteLength(pathDescriptor) ??
(pathDescriptor == 0xFF ? 0 : null);
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
return null;
}
index += pathByteLen;
final payload = rawPacketData.sublist(index);
final senderHash = route?.hopHashes.isNotEmpty == true
? route!.hopHashes.first
: null;
switch (payloadType) {
case 0x05:
case 0x06:
if (payload.isEmpty) return const _ParsedTrafficPayload();
return _ParsedTrafficPayload(
endpointLine:
'Channel Hash: ${payload.first.toRadixString(16).padLeft(2, '0').toUpperCase()}',
);
case 0x00:
case 0x01:
case 0x07:
if (payload.isEmpty) return const _ParsedTrafficPayload();
final destinationHash = payload.first
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
return _ParsedTrafficPayload(
endpointLine: senderHash == null
? 'To: <$destinationHash>'
: 'From: <${senderHash.toUpperCase()}> To: <$destinationHash>',
);
default:
return const _ParsedTrafficPayload();
}
}
}
class _PacketInfoLine extends StatelessWidget {
final String text;
const _PacketInfoLine({required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
text,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurface,
),
),
);
}
}
class _GeoPoint {
final double latitude;
final double longitude;
@@ -958,44 +1113,6 @@ class _GeoPoint {
const _GeoPoint(this.latitude, this.longitude);
}
class _PacketMetaChip extends StatelessWidget {
final String label;
final bool emphasized;
final VoidCallback? onTap;
const _PacketMetaChip({
required this.label,
this.emphasized = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = emphasized ? scheme.primary : scheme.outline;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color.withValues(alpha: emphasized ? 0.12 : 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.22)),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
),
);
}
}
void openLiveTrafficScreen(BuildContext context, ConnectionProvider provider) {
Navigator.push(
context,

File diff suppressed because it is too large Load Diff

View File

@@ -61,9 +61,10 @@ class _MessagesTabState extends State<MessagesTab> {
Timer? _highlightTimer; // Timer for clearing message highlight
Timer? _channelReadTimer;
String? _pendingChannelReadKey;
TextEditingValue _lastComposerValue = const TextEditingValue();
bool _isMentionPickerOpen = false;
bool _suppressMentionTrigger = false;
TextRange? _activeMentionRange;
String _mentionQuery = '';
List<Contact> _mentionSuggestions = const [];
// Message destination state
String _destinationType =
@@ -92,8 +93,8 @@ class _MessagesTabState extends State<MessagesTab> {
@override
void initState() {
super.initState();
_lastComposerValue = _textController.value;
_textController.addListener(_handleComposerChanged);
_focusNode.addListener(_handleFocusChanged);
// Load saved message destination
_loadSavedDestination();
_loadVoiceSettings();
@@ -126,6 +127,7 @@ class _MessagesTabState extends State<MessagesTab> {
_channelReadTimer?.cancel();
_voiceStreamSub?.cancel();
_voiceRecorder.dispose();
_focusNode.removeListener(_handleFocusChanged);
_textController.dispose();
_focusNode.dispose();
_scrollController.dispose();
@@ -161,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
@@ -195,25 +201,13 @@ class _MessagesTabState extends State<MessagesTab> {
}
void _handleComposerChanged() {
final previousValue = _lastComposerValue;
final currentValue = _textController.value;
_lastComposerValue = currentValue;
_updateCharacterCount();
if (_suppressMentionTrigger || _isMentionPickerOpen) {
if (_suppressMentionTrigger) {
return;
}
final mentionTriggerRange = _getMentionTriggerRange(
previousValue: previousValue,
currentValue: currentValue,
);
if (mentionTriggerRange == null) {
return;
}
unawaited(_showMentionSelectorForRange(mentionTriggerRange));
_updateMentionSuggestions(_textController.value);
}
void _updateCharacterCount() {
@@ -222,43 +216,112 @@ class _MessagesTabState extends State<MessagesTab> {
});
}
TextRange? _getMentionTriggerRange({
required TextEditingValue previousValue,
required TextEditingValue currentValue,
}) {
if (!previousValue.selection.isValid ||
!currentValue.selection.isValid ||
!previousValue.selection.isCollapsed ||
!currentValue.selection.isCollapsed) {
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 previousOffset = previousValue.selection.baseOffset;
final currentOffset = currentValue.selection.baseOffset;
if (previousOffset < 0 || currentOffset < 0) {
final cursorOffset = value.selection.baseOffset;
if (cursorOffset <= 0) {
return null;
}
if (currentValue.text.length != previousValue.text.length + 1 ||
currentOffset != previousOffset + 1) {
final text = value.text;
final triggerStart = text.lastIndexOf('@', cursorOffset - 1);
if (triggerStart == -1) {
return null;
}
if (currentValue.text.substring(0, previousOffset) !=
previousValue.text.substring(0, previousOffset)) {
final leadingChar = triggerStart > 0 ? text[triggerStart - 1] : null;
if (leadingChar != null && !RegExp(r'[\s\(\[\{]').hasMatch(leadingChar)) {
return null;
}
if (currentValue.text.substring(currentOffset) !=
previousValue.text.substring(previousOffset)) {
final query = text.substring(triggerStart + 1, cursorOffset);
if (query.contains(RegExp(r'[\s@\[\]\n\r]'))) {
return null;
}
if (currentValue.text[previousOffset] != '@') {
return null;
}
return TextRange(start: triggerStart, end: cursorOffset);
}
return TextRange(start: previousOffset, end: currentOffset);
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 =>
@@ -378,56 +441,6 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
Future<void> _showMentionSelectorForRange(TextRange triggerRange) async {
final contactsProvider = context.read<ContactsProvider>();
final contacts = contactsProvider.contacts
.where((contact) => contact.type == ContactType.chat)
.toList();
if (contacts.isEmpty || !mounted) {
return;
}
_isMentionPickerOpen = true;
Contact? selectedContact;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => RecipientSelectorSheet(
contacts: contacts,
rooms: const [],
channels: const [],
unreadCount: 0,
unreadCountsByPublicKey: {
for (final contact in contacts) contact.publicKeyHex: 0,
},
currentDestinationType: null,
currentRecipientPublicKey: null,
showAllOption: false,
onSelect: (_, recipient) {
selectedContact = recipient;
},
),
);
_isMentionPickerOpen = false;
if (!mounted) {
return;
}
if (selectedContact != null) {
_insertReplyMention(
selectedContact!.displayName,
replacementRange: triggerRange,
);
}
_focusNode.requestFocus();
}
/// Handle recipient selection
Future<void> _onRecipientSelected(String type, Contact? recipient) async {
setState(() {
@@ -489,15 +502,23 @@ class _MessagesTabState extends State<MessagesTab> {
selection: TextSelection.collapsed(offset: nextOffset),
composing: TextRange.empty,
);
_lastComposerValue = _textController.value;
_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;
@@ -513,7 +534,7 @@ class _MessagesTabState extends State<MessagesTab> {
}).firstOrNull;
if (recipient == null) {
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound);
return;
}
}
@@ -522,28 +543,48 @@ class _MessagesTabState extends State<MessagesTab> {
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;
}
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;
destinationType = recipient.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact;
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, AppLocalizations.of(context)!.cannotReplySenderMissing);
return;
}
recipient = contactsProvider.findContactByPrefix(senderPrefix);
if (recipient == null) {
ToastLogger.error(context, AppLocalizations.of(context)!.cannotReplyContactNotFound);
return;
}
destinationType = recipient.isRoom
? MessageDestinationPreferences.destinationTypeRoom
: MessageDestinationPreferences.destinationTypeContact;
}
}
await _onRecipientSelected(destinationType, recipient);
if (!mounted) return;
if (message.isChannelMessage && senderContact != null) {
_insertReplyMention(senderContact.displayName);
if ((message.isChannelMessage || recipient?.isRoom == true) &&
senderDisplayName != null &&
senderDisplayName.isNotEmpty) {
_insertReplyMention(senderDisplayName);
}
_focusNode.requestFocus();
}
@@ -1334,7 +1375,7 @@ class _MessagesTabState extends State<MessagesTab> {
final decision = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Send to Public Channel?'),
title: Text(AppLocalizations.of(context)!.sendToPublicChannel),
content: Text(
'You are about to send $mediaType to the Public Channel. '
'This is not advised because everyone on the mesh may receive it. '
@@ -1347,7 +1388,7 @@ class _MessagesTabState extends State<MessagesTab> {
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Send anyway'),
child: Text(AppLocalizations.of(context)!.sendAnyway),
),
],
),
@@ -1407,8 +1448,8 @@ class _MessagesTabState extends State<MessagesTab> {
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.search),
title: const Text('Search messages'),
leading: Icon(Icons.search),
title: Text(AppLocalizations.of(context)!.searchMessages),
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
_showFilteredMessageSearch();
@@ -1416,7 +1457,7 @@ class _MessagesTabState extends State<MessagesTab> {
},
),
ListTile(
leading: const Icon(Icons.add_location_alt),
leading: Icon(Icons.add_location_alt),
title: Text(AppLocalizations.of(context)!.sendSarMarker),
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
@@ -1443,8 +1484,8 @@ class _MessagesTabState extends State<MessagesTab> {
),
ListTile(
enabled: !_isSendingImage,
leading: const Icon(Icons.photo_library),
title: const Text('Send image from gallery'),
leading: Icon(Icons.photo_library),
title: Text(AppLocalizations.of(context)!.sendImageFromGallery),
onTap: _isSendingImage
? null
: () async {
@@ -1455,8 +1496,8 @@ class _MessagesTabState extends State<MessagesTab> {
),
ListTile(
enabled: !_isSendingImage,
leading: const Icon(Icons.camera_alt),
title: const Text('Take photo'),
leading: Icon(Icons.camera_alt),
title: Text(AppLocalizations.of(context)!.takePhoto),
onTap: _isSendingImage
? null
: () async {
@@ -1466,9 +1507,9 @@ class _MessagesTabState extends State<MessagesTab> {
},
),
ListTile(
leading: const Icon(Icons.grid_3x3),
title: const Text('Start Tic-Tac-Toe'),
subtitle: const Text('DM only'),
leading: Icon(Icons.grid_3x3),
title: Text(AppLocalizations.of(context)!.startTictactoe),
subtitle: Text(AppLocalizations.of(context)!.dmOnly),
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
await _startTicTacToeGame();
@@ -1978,12 +2019,19 @@ class _MessagesTabState extends State<MessagesTab> {
void _handleMessageTap(Message message) {
if (widget.onNavigateToMap == null) return;
if (message.isSarMarker && message.sarGpsCoordinates != null) {
if (message.isSarMarker) {
final mapProvider = context.read<MapProvider>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
final marker = message.toSarMarker();
if (marker == null) {
return;
}
final error = mapProvider.navigateToSarMarker(marker);
if (error != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
return;
}
widget.onNavigateToMap?.call();
return;
}
@@ -1992,7 +2040,16 @@ class _MessagesTabState extends State<MessagesTab> {
debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}');
final mapProvider = context.read<MapProvider>();
final drawingProvider = context.read<DrawingProvider>();
mapProvider.navigateToDrawing(message.drawingId!, drawingProvider);
final error = mapProvider.navigateToDrawing(
message.drawingId!,
drawingProvider,
);
if (error != null && mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
return;
}
widget.onNavigateToMap?.call();
}
}
@@ -2002,7 +2059,9 @@ class _MessagesTabState extends State<MessagesTab> {
return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) {
_syncChannelAutoReadTimer(messagesProvider);
final messages = _getFilteredMessages(messagesProvider);
final messages = messagesProvider.buildDisplayMessages(
_getFilteredMessages(messagesProvider),
);
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
@@ -2040,6 +2099,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

@@ -61,7 +61,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('No logs to export')));
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.noLogsToExport)));
}
return;
}
@@ -108,7 +108,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('No logs to export')));
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.noLogsToExport)));
}
return;
}
@@ -155,8 +155,8 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
void _copyToClipboard(BuildContext context, BlePacketLog log) {
Clipboard.setData(ClipboardData(text: log.hexData));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Hex data copied to clipboard'),
SnackBar(
content: Text(AppLocalizations.of(context)!.hexDataCopiedToClipboard),
duration: Duration(seconds: 1),
),
);
@@ -184,7 +184,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
setState(() {});
if (parentContext.mounted) {
ScaffoldMessenger.of(parentContext).showSnackBar(
const SnackBar(content: Text('Packet logs cleared')),
SnackBar(content: Text(AppLocalizations.of(context)!.packetLogsCleared)),
);
}
},
@@ -205,7 +205,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('BLE Packet Logs'),
Text(AppLocalizations.of(context)!.blePacketLogs),
Text(
'${logs.length} packets',
style: Theme.of(context).textTheme.bodySmall,
@@ -316,23 +316,23 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
icon: const Icon(Icons.share),
tooltip: 'Export logs',
itemBuilder: (context) => [
const PopupMenuItem(
PopupMenuItem(
value: 'csv',
child: Row(
children: [
Icon(Icons.table_chart),
SizedBox(width: 8),
Text('Export as CSV'),
const Icon(Icons.table_chart),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.exportAsCsv),
],
),
),
const PopupMenuItem(
PopupMenuItem(
value: 'txt',
child: Row(
children: [
Icon(Icons.text_snippet),
SizedBox(width: 8),
Text('Export as Text'),
const Icon(Icons.text_snippet),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.exportAsText),
],
),
),
@@ -411,8 +411,8 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
_filterDirection = null;
});
},
icon: const Icon(Icons.clear_all),
label: const Text('Clear filters'),
icon: Icon(Icons.clear_all),
label: Text(AppLocalizations.of(context)!.clearFilters),
),
],
],
@@ -519,24 +519,24 @@ class _PacketLogCard extends StatelessWidget {
children: [
_FactCard(
icon: isRx ? Icons.call_received : Icons.call_made,
label: 'Direction',
label: AppLocalizations.of(context)!.direction,
value: isRx ? 'RX' : 'TX',
accent: directionColor,
),
_FactCard(
icon: Icons.data_object,
label: 'Size',
label: AppLocalizations.of(context)!.size,
value: '${log.rawData.length} bytes',
),
_FactCard(
icon: Icons.schedule,
label: 'Captured',
label: AppLocalizations.of(context)!.captured,
value: _formatTimestamp(log.timestamp),
),
if (log.responseCode != null)
_FactCard(
icon: Icons.sell,
label: 'Opcode',
label: AppLocalizations.of(context)!.opcode,
value: log.opcodeName,
),
],
@@ -565,7 +565,7 @@ class _PacketLogCard extends StatelessWidget {
const SizedBox(height: 10),
if (rxInfo?.rssiDbm != null)
_SignalMeter(
label: 'RSSI',
label: AppLocalizations.of(context)!.rssi,
valueLabel: '${rxInfo!.rssiDbm} dBm',
normalized: _normalizeRssi(
rxInfo.rssiDbm!.toDouble(),
@@ -573,9 +573,9 @@ class _PacketLogCard extends StatelessWidget {
color: _rssiColor(rxInfo.rssiDbm!.toDouble()),
),
if (rxInfo?.snrDb != null) ...[
const SizedBox(height: 8),
SizedBox(height: 8),
_SignalMeter(
label: 'SNR',
label: AppLocalizations.of(context)!.snr,
valueLabel:
'${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
normalized: _normalizeSnr(rxInfo.snrDb!),
@@ -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) {
@@ -865,26 +854,20 @@ class _RouteSection extends StatelessWidget {
children: [
_FactCard(
icon: Icons.route,
label: 'Payload',
label: AppLocalizations.of(context)!.payload,
value: _payloadTypeLabel(route.payloadType),
),
_FactCard(
icon: Icons.hub,
label: 'Hops',
label: AppLocalizations.of(context)!.hops,
value: '${route.hopCount}',
),
_FactCard(
icon: Icons.tag,
label: 'Hash size',
label: AppLocalizations.of(context)!.hashSize,
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,251 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/config_profile.dart';
import '../services/profile_manager.dart';
import '../services/profile_workspace_coordinator.dart';
import '../l10n/app_localizations.dart';
class ProfilesScreen extends StatelessWidget {
const ProfilesScreen({super.key});
@override
Widget build(BuildContext context) {
return Consumer<ProfileManager>(
builder: (context, profileManager, child) {
final profiles = profileManager.visibleProfiles;
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.profiles),
actions: [
IconButton(
onPressed: () async {
await context
.read<ProfileWorkspaceCoordinator>()
.importProfileFromFile();
},
icon: const Icon(Icons.file_open),
tooltip: 'Import profile',
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _createProfile(context),
icon: Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.newProfile),
),
body: profiles.isEmpty
? Center(
child: Text(AppLocalizations.of(context)!.enableProfilesToStartManagingThem),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: profiles.length,
itemBuilder: (context, index) {
final profile = profiles[index];
final isActive =
profileManager.activeProfileId == profile.id;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
profile.name,
style: Theme.of(
context,
).textTheme.titleMedium,
),
),
if (isActive)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(999),
),
child: Text(AppLocalizations.of(context)!.active),
),
],
),
const SizedBox(height: 8),
Text(_summary(profile)),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton(
onPressed: () async {
await context
.read<ProfileWorkspaceCoordinator>()
.openProfile(profile.id);
},
child: Text(AppLocalizations.of(context)!.open),
),
OutlinedButton(
onPressed: () async {
final resolved =
profile.id ==
ConfigProfile.defaultProfileId
? await context
.read<
ProfileWorkspaceCoordinator
>()
.snapshotCurrentProfile(
id: profile.id,
name: profile.name,
)
: profile;
if (!context.mounted) return;
await context
.read<ProfileWorkspaceCoordinator>()
.exportProfile(resolved);
},
child: Text(AppLocalizations.of(context)!.share),
),
PopupMenuButton<String>(
onSelected: (value) async {
switch (value) {
case 'duplicate':
await context
.read<ProfileWorkspaceCoordinator>()
.duplicateProfile(profile);
break;
case 'rename':
await _renameProfile(context, profile);
break;
case 'delete':
await context
.read<ProfileWorkspaceCoordinator>()
.deleteProfile(profile);
break;
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'duplicate',
child: Text(AppLocalizations.of(context)!.duplicate),
),
if (!profile.isDefault)
PopupMenuItem(
value: 'rename',
child: Text(AppLocalizations.of(context)!.rename),
),
if (!profile.isDefault)
const PopupMenuItem(
value: 'delete',
child: Text('Delete'),
),
],
),
],
),
],
),
),
);
},
),
);
},
);
}
String _summary(ConfigProfile profile) {
if (profile.isDefault) {
return 'Current app state and history.';
}
final sections = <String>[];
if (profile.sections.deviceConfig?.isEmpty == false) {
sections.add('Device');
}
if (profile.sections.channels.isNotEmpty) {
sections.add('${profile.sections.channels.length} channels');
}
if (profile.sections.appSettings?.isEmpty == false) {
sections.add('App settings');
}
if (profile.sections.mapWorkspace?.isEmpty == false) {
sections.add('Map workspace');
}
return sections.isEmpty ? 'Empty profile' : sections.join(' | ');
}
Future<void> _createProfile(BuildContext context) async {
final controller = TextEditingController();
final name = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.createProfile),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(labelText: 'Profile name'),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
child: Text(AppLocalizations.of(context)!.create),
),
],
),
);
if (name == null || name.isEmpty || !context.mounted) {
return;
}
await context.read<ProfileWorkspaceCoordinator>().createProfileFromCurrent(
name: name,
);
}
Future<void> _renameProfile(
BuildContext context,
ConfigProfile profile,
) async {
final controller = TextEditingController(text: profile.name);
final name = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.renameProfile),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(labelText: 'Profile name'),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
child: const Text('Save'),
),
],
),
);
if (name == null || name.isEmpty || !context.mounted) {
return;
}
await context.read<ProfileWorkspaceCoordinator>().renameProfile(
profile,
name,
);
}
}

View File

@@ -10,6 +10,7 @@ import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../l10n/app_localizations.dart';
class RepeatersMapScreen extends StatefulWidget {
const RepeatersMapScreen({super.key});
@@ -191,18 +192,18 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 12),
SizedBox(height: 12),
_InfoLine(
label: 'Public key',
label: AppLocalizations.of(context)!.publicKeyLabel,
value: repeater.publicKey,
monospace: true,
),
_InfoLine(
label: 'Coordinates',
label: AppLocalizations.of(context)!.coordinates,
value:
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
),
_InfoLine(label: 'Source', value: repeater.sourceLabel),
_InfoLine(label: AppLocalizations.of(context)!.source, value: repeater.sourceLabel),
const SizedBox(height: 12),
if (canAdd)
SizedBox(
@@ -233,8 +234,8 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
width: double.infinity,
child: FilledButton.icon(
onPressed: null,
icon: const Icon(Icons.check_circle),
label: const Text('Already in contacts'),
icon: Icon(Icons.check_circle),
label: Text(AppLocalizations.of(context)!.alreadyInContacts),
),
),
],
@@ -253,8 +254,8 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Connect to a device before adding contacts'),
SnackBar(
content: Text(AppLocalizations.of(context)!.connectToADeviceBeforeAddingContacts),
),
);
return;
@@ -366,7 +367,7 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('Repeaters Map'),
title: Text(AppLocalizations.of(context)!.repeatersMap),
actions: [
IconButton(
onPressed: _isLoading
@@ -446,18 +447,18 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: const [
children: [
_LegendRow(
color: Color(0xFF7C3AED),
label: 'From contacts',
color: const Color(0xFF7C3AED),
label: AppLocalizations.of(context)!.fromContacts,
),
SizedBox(height: 8),
const SizedBox(height: 8),
_LegendRow(
color: Color(0xFFE8681D),
label: 'Online only',
color: const Color(0xFFE8681D),
label: AppLocalizations.of(context)!.onlineOnly,
),
SizedBox(height: 8),
_LegendRow(color: Color(0xFF1B8F4F), label: 'In both'),
const SizedBox(height: 8),
_LegendRow(color: const Color(0xFF1B8F4F), label: AppLocalizations.of(context)!.inBoth),
],
),
),

View File

@@ -240,7 +240,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
PopupMenuItem(
value: 'import',
child: ListTile(
leading: const Icon(Icons.download),
leading: Icon(Icons.download),
title: Text(l10n.importFromClipboard),
contentPadding: EdgeInsets.zero,
),
@@ -248,7 +248,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
PopupMenuItem(
value: 'export',
child: ListTile(
leading: const Icon(Icons.upload),
leading: Icon(Icons.upload),
title: Text(l10n.exportToClipboard),
contentPadding: EdgeInsets.zero,
),
@@ -257,7 +257,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
PopupMenuItem(
value: 'reset',
child: ListTile(
leading: const Icon(Icons.restart_alt),
leading: Icon(Icons.restart_alt),
title: Text(l10n.resetToDefaults),
contentPadding: EdgeInsets.zero,
),
@@ -283,14 +283,14 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
size: 64,
color: colorScheme.onSurface.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
SizedBox(height: 16),
Text(
l10n.noTemplates,
style: theme.textTheme.titleMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 8),
SizedBox(height: 8),
Text(
l10n.tapAddToCreate,
style: theme.textTheme.bodyMedium?.copyWith(
@@ -317,7 +317,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _addTemplate,
icon: const Icon(Icons.add),
icon: Icon(Icons.add),
label: Text(l10n.addTemplate),
),
);

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,7 @@ import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart';
import '../models/config_profile.dart';
import '../services/location_tracking_service.dart';
import '../services/locale_preferences.dart';
import '../services/mesh_map_nodes_service.dart';
@@ -25,14 +26,18 @@ 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 '../services/profile_manager.dart';
import '../services/profile_workspace_coordinator.dart';
import '../services/profiles_feature_service.dart';
import '../utils/sample_data_generator.dart';
import '../utils/image_message_parser.dart';
import '../utils/voice_message_parser.dart';
import '../theme/app_theme.dart';
import '../l10n/app_localizations.dart';
import '../widgets/connection_mode_selector.dart';
import '../widgets/update_dialog.dart';
import 'sar_template_management_screen.dart';
import 'profiles_screen.dart';
import 'welcome_wizard_screen.dart';
class SettingsScreen extends StatefulWidget {
@@ -77,7 +82,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _rotateMapWithHeading = false;
bool _showMapDebugInfo = false;
bool _openMapInFullscreen = false;
bool _messageNotificationsEnabled = true;
bool _sarNotificationsEnabled = true;
bool _discoveryNotificationsEnabled = true;
bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true;
bool _isDeveloperModeEnabled = false;
bool _profilesEnabled = false;
DateTime? _onlineTraceCacheUpdatedAt;
bool _isClearingOnlineTraceCache = false;
int _versionTapCount = 0;
@@ -97,8 +108,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadImagePreferences();
_loadFastLocationSettings();
_loadDeveloperMode();
_loadProfilesEnabled();
_loadOnlineTraceCacheStatus();
_loadMapPreferences();
_loadNotificationPreferences();
}
@override
@@ -123,7 +136,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true;
_showRxTxIndicators =
prefs.getBool(
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
) ??
true;
});
}
}
@@ -136,6 +153,27 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
}
Future<void> _loadProfilesEnabled() async {
final isEnabled = await ProfilesFeatureService.isEnabled();
if (!mounted) return;
setState(() {
_profilesEnabled = isEnabled;
});
}
Future<void> _loadNotificationPreferences() async {
final service = NotificationService();
await service.initialize();
if (!mounted) return;
setState(() {
_messageNotificationsEnabled = service.messageNotificationsEnabled;
_sarNotificationsEnabled = service.sarNotificationsEnabled;
_discoveryNotificationsEnabled = service.discoveryNotificationsEnabled;
_updateNotificationsEnabled = service.updateNotificationsEnabled;
_muteForegroundNotifications = service.muteForegroundNotifications;
});
}
Future<void> _loadOnlineTraceCacheStatus() async {
final cachedAt = await MeshMapNodesService.cachedAt();
if (!mounted) return;
@@ -154,7 +192,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Developer mode disabled')));
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeDisabled)));
return;
}
@@ -168,7 +206,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Developer mode enabled')));
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.developerModeEnabled)));
return;
}
@@ -180,7 +218,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _saveRxTxPreference(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('show_rx_tx_indicators', value);
await prefs.setBool(
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
value,
);
}
Future<void> _loadMapPreferences() async {
@@ -188,15 +229,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
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;
prefs.getBool(
ProfileStorageScope.scopedKey('map_rotate_with_heading'),
) ??
false;
_showMapDebugInfo =
prefs.getBool(ProfileStorageScope.scopedKey('map_show_debug_info')) ??
false;
_openMapInFullscreen =
prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ??
false;
});
}
Future<void> _saveMapPreference(String key, bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(key, value);
await prefs.setBool(ProfileStorageScope.scopedKey(key), value);
}
Future<void> _loadVoicePreferences() async {
@@ -280,7 +328,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final value = await showDialog<double>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Fast GPS movement threshold'),
title: Text(AppLocalizations.of(context)!.fastGpsMovementThreshold),
content: TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
@@ -321,7 +369,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final value = await showDialog<int>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Fast GPS active-use interval'),
title: Text(AppLocalizations.of(context)!.fastGpsActiveuseInterval),
content: TextField(
controller: controller,
keyboardType: TextInputType.number,
@@ -469,7 +517,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
// Load settings and restore tracking state
final prefs = await SharedPreferences.getInstance();
final wasTracking =
prefs.getBool('background_tracking_enabled') ?? false;
prefs.getBool(
ProfileStorageScope.scopedKey('background_tracking_enabled'),
) ??
false;
if (wasTracking) {
await _startBackgroundTracking();
@@ -507,8 +558,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Update check is only available on Android'),
SnackBar(
content: Text(AppLocalizations.of(context)!.updateCheckIsOnlyAvailableOnAndroid),
backgroundColor: Colors.orange,
),
);
@@ -532,8 +583,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (!updateInfo.isAvailable) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('You are running the latest version'),
SnackBar(
content: Text(AppLocalizations.of(context)!.youAreRunningTheLatestVersion),
backgroundColor: Colors.green,
),
);
@@ -542,8 +593,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (updateInfo.downloadUrl == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Update available but download URL not found'),
SnackBar(
content: Text(AppLocalizations.of(context)!.updateAvailableButDownloadUrlNotFound),
backgroundColor: Colors.orange,
),
);
@@ -685,7 +736,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: Row(
children: [
const Icon(Icons.settings, size: 24),
const SizedBox(width: 12),
SizedBox(width: 12),
Text(AppLocalizations.of(context)!.locationPermission),
],
),
@@ -827,7 +878,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Messages'),
title: Text(AppLocalizations.of(context)!.clearMessages),
content: const Text(
'This will permanently delete all stored messages. Are you sure?',
),
@@ -856,8 +907,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('All messages cleared'),
SnackBar(
content: Text(AppLocalizations.of(context)!.allMessagesCleared),
backgroundColor: Colors.orange,
),
);
@@ -867,7 +918,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear online trace database'),
title: Text(AppLocalizations.of(context)!.clearOnlineTraceDatabase),
content: const Text(
'This removes the cached online node database used as a trace fallback.',
),
@@ -900,8 +951,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Online trace database cleared'),
SnackBar(
content: Text(AppLocalizations.of(context)!.onlineTraceDatabaseCleared),
backgroundColor: Colors.orange,
),
);
@@ -927,9 +978,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
final selected = await showDialog<int>(
context: context,
builder: (context) => SimpleDialog(
title: const Text('Route path byte size'),
title: Text(AppLocalizations.of(context)!.routePathByteSize),
children: [
for (final value in [1, 2, 3])
for (final value in RouteHashPreferences.supportedSizes)
SimpleDialogOption(
onPressed: () => Navigator.of(context).pop(value),
child: Row(
@@ -939,7 +990,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('$value byte${value == 1 ? '' : 's'}'),
Text('$value-byte (max ${64 ~/ value} hops)'),
Text(
'Use ${value * 2} hex characters per hop',
style: Theme.of(context).textTheme.bodySmall,
@@ -973,21 +1024,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Appearance'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.palette),
leading: Icon(Icons.palette),
title: Text(AppLocalizations.of(context)!.theme),
subtitle: Text(AppTheme.getThemeDisplayName(_selectedTheme)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeDialog(),
),
ListTile(
leading: const Icon(Icons.language),
leading: Icon(Icons.language),
title: Text(AppLocalizations.of(context)!.language),
subtitle: Text(LocalePreferences.getDisplayName(_selectedLocale)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(),
),
SwitchListTile(
secondary: const Icon(Icons.radar),
secondary: Icon(Icons.radar),
title: Text(AppLocalizations.of(context)!.showRxTxIndicators),
subtitle: Text(
AppLocalizations.of(context)!.displayPacketActivity,
@@ -1002,11 +1053,93 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
]),
_buildSectionHeader('Notifications'),
_buildSettingsCard([
SwitchListTile(
secondary: Icon(Icons.chat_bubble_outline),
title: Text(AppLocalizations.of(context)!.messageNotifications),
subtitle: const Text(
'Notify for incoming direct and channel messages',
),
value: _messageNotificationsEnabled,
onChanged: (value) async {
setState(() {
_messageNotificationsEnabled = value;
});
await NotificationService().setMessageNotificationsEnabled(
value,
);
},
),
SwitchListTile(
secondary: Icon(Icons.warning_amber_outlined),
title: Text(AppLocalizations.of(context)!.sarAlerts),
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: Icon(Icons.contact_page_outlined),
title: Text(AppLocalizations.of(context)!.discoveryNotifications),
subtitle: const Text(
'Notify when new contacts appear in Discovery',
),
value: _discoveryNotificationsEnabled,
onChanged: (value) async {
setState(() {
_discoveryNotificationsEnabled = value;
});
await NotificationService().setDiscoveryNotificationsEnabled(
value,
);
},
),
SwitchListTile(
secondary: Icon(Icons.system_update),
title: Text(AppLocalizations.of(context)!.updateNotifications),
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: Icon(Icons.visibility_off_outlined),
title: Text(AppLocalizations.of(context)!.muteWhileAppIsOpen),
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>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.map_outlined),
secondary: Icon(Icons.map_outlined),
title: Text(AppLocalizations.of(context)!.disableMap),
subtitle: Text(
AppLocalizations.of(context)!.disableMapDescription,
@@ -1019,8 +1152,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.contacts_outlined),
title: const Text('Disable Contacts'),
secondary: Icon(Icons.contacts_outlined),
title: Text(AppLocalizations.of(context)!.disableContacts),
subtitle: const Text(
'Hide the contacts tab to simplify navigation',
),
@@ -1032,8 +1165,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.sensors),
title: const Text('Enable Sensors tab'),
secondary: Icon(Icons.sensors),
title: Text(AppLocalizations.of(context)!.enableSensorsTab),
subtitle: const Text(
'Show a dedicated tab for watched relay and node telemetry',
),
@@ -1047,22 +1180,9 @@ 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'),
leading: Icon(Icons.alt_route),
title: Text(AppLocalizations.of(context)!.routePathByteSize),
subtitle: Text(
'$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes',
),
@@ -1071,8 +1191,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.swap_horiz),
title: const Text('Auto route rotation'),
secondary: Icon(Icons.swap_horiz),
title: Text(AppLocalizations.of(context)!.autoRouteRotation),
subtitle: const Text(
'Rotate between best known direct paths and flood mode for room/contact sends',
),
@@ -1084,8 +1204,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.route),
title: const Text('Nearest repeater fallback'),
secondary: Icon(Icons.route),
title: Text(AppLocalizations.of(context)!.nearestRepeaterFallback),
subtitle: const Text(
'After normal retries fail, try one final resend through the nearest repeater',
),
@@ -1097,8 +1217,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.route),
title: const Text('Clear path on max retry'),
secondary: Icon(Icons.route),
title: Text(AppLocalizations.of(context)!.clearPathOnMaxRetry),
subtitle: const Text(
'Clear the route only after all retries and final router fallback fail',
),
@@ -1114,13 +1234,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
'Clear Messages',
style: TextStyle(color: Colors.red),
),
subtitle: const Text('Delete all stored message history'),
subtitle: Text(AppLocalizations.of(context)!.deleteAllStoredMessageHistory),
onTap: _clearMessages,
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => ListTile(
leading: const Icon(Icons.format_size),
title: const Text('Message font size'),
leading: Icon(Icons.format_size),
title: Text(AppLocalizations.of(context)!.messageFontSize),
subtitle: Text(
'${(appProvider.messageFontScale * 100).round()}% of default',
),
@@ -1144,8 +1264,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Tracing'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.cloud_sync),
title: const Text('Online trace database'),
leading: Icon(Icons.cloud_sync),
title: Text(AppLocalizations.of(context)!.onlineTraceDatabase),
subtitle: Text(_onlineTraceCacheSubtitle()),
),
ListTile(
@@ -1179,8 +1299,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Map'),
_buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.explore),
title: const Text('Rotate map with heading'),
secondary: Icon(Icons.explore),
title: Text(AppLocalizations.of(context)!.rotateMapWithHeading),
subtitle: const Text(
'Rotate the map based on your compass or movement heading',
),
@@ -1193,8 +1313,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
SwitchListTile(
secondary: const Icon(Icons.bug_report_outlined),
title: const Text('Show map debug info'),
secondary: Icon(Icons.bug_report_outlined),
title: Text(AppLocalizations.of(context)!.showMapDebugInfo),
subtitle: const Text(
'Display extra map diagnostics and internal state overlays',
),
@@ -1207,8 +1327,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
SwitchListTile(
secondary: const Icon(Icons.fullscreen),
title: const Text('Open map in fullscreen'),
secondary: Icon(Icons.fullscreen),
title: Text(AppLocalizations.of(context)!.openMapInFullscreen),
subtitle: const Text(
'Start the map tab in fullscreen mode by default',
),
@@ -1222,11 +1342,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
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',
),
secondary: Icon(Icons.fmd_good_outlined),
title: Text(AppLocalizations.of(context)!.showSarMarkersLabel),
subtitle: Text(AppLocalizations.of(context)!.displaySarMarkersOnTheMainMap),
value: drawingProvider.showSarMarkers,
onChanged: (value) {
drawingProvider.toggleSarMarkers();
@@ -1235,8 +1353,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: const Icon(Icons.timeline),
title: const Text('Show all contact trails'),
secondary: Icon(Icons.timeline),
title: Text(AppLocalizations.of(context)!.showAllContactTrailsLabel),
subtitle: const Text(
'Display location trails for all contacts that have history',
),
@@ -1248,8 +1366,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<MapProvider>(
builder: (context, mapProvider, child) => SwitchListTile(
secondary: const Icon(Icons.router_outlined),
title: const Text('Hide repeaters on map'),
secondary: Icon(Icons.router_outlined),
title: Text(AppLocalizations.of(context)!.hideRepeatersOnMap),
subtitle: const Text(
'Hide repeater contacts from the main map view',
),
@@ -1279,16 +1397,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.graphic_eq),
title: const Text('Voice bitrate'),
leading: Icon(Icons.graphic_eq),
title: Text(AppLocalizations.of(context)!.voiceBitrate),
subtitle: Text(_voiceBitrateSubtitle(_voiceBitrate)),
trailing: const Icon(Icons.chevron_right),
onTap: _showVoiceBitrateDialog,
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.tune),
title: const Text('Band-pass filter voice'),
secondary: Icon(Icons.tune),
title: Text(AppLocalizations.of(context)!.bandpassFilterVoice),
subtitle: const Text(
'Keeps speech frequencies and cuts low/high noise',
),
@@ -1300,9 +1418,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.compress),
title: const Text('Voice compressor'),
subtitle: const Text('Balances quiet and loud speech levels'),
secondary: Icon(Icons.compress),
title: Text(AppLocalizations.of(context)!.voiceCompressor),
subtitle: Text(AppLocalizations.of(context)!.balancesQuietAndLoudSpeechLevels),
value: appProvider.isVoiceCompressorEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceCompressorEnabled(value);
@@ -1311,9 +1429,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.speed),
title: const Text('Voice limiter'),
subtitle: const Text('Prevents clipping peaks before encoding'),
secondary: Icon(Icons.speed),
title: Text(AppLocalizations.of(context)!.voiceLimiter),
subtitle: Text(AppLocalizations.of(context)!.preventsClippingPeaksBeforeEncoding),
value: appProvider.isVoiceLimiterEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceLimiterEnabled(value);
@@ -1322,9 +1440,9 @@ 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'),
secondary: Icon(Icons.auto_fix_high),
title: Text(AppLocalizations.of(context)!.micAutoGain),
subtitle: Text(AppLocalizations.of(context)!.letsTheRecorderAdjustInputLevel),
value: appProvider.isVoiceAutoGainEnabled,
onChanged: (value) async {
await appProvider.toggleVoiceAutoGainEnabled(value);
@@ -1333,8 +1451,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.hearing_disabled),
title: const Text('Echo cancellation'),
secondary: Icon(Icons.hearing_disabled),
title: Text(AppLocalizations.of(context)!.echoCancellation),
subtitle: const Text(
'Uses recorder echo cancellation if available',
),
@@ -1346,8 +1464,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.noise_control_off),
title: const Text('Noise suppression'),
secondary: Icon(Icons.noise_control_off),
title: Text(AppLocalizations.of(context)!.noiseSuppression),
subtitle: const Text(
'Uses recorder noise suppression if available',
),
@@ -1359,8 +1477,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
Consumer<AppProvider>(
builder: (context, appProvider, child) => SwitchListTile(
secondary: const Icon(Icons.content_cut),
title: const Text('Trim silence in voice messages'),
secondary: Icon(Icons.content_cut),
title: Text(AppLocalizations.of(context)!.trimSilenceInVoiceMessages),
subtitle: const Text(
'Removes long silent parts before sending voice',
),
@@ -1375,15 +1493,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Images'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.photo_size_select_large),
title: const Text('Max image size'),
leading: Icon(Icons.photo_size_select_large),
title: Text(AppLocalizations.of(context)!.maxImageSize),
subtitle: Text('$_imageMaxSize×$_imageMaxSize px'),
trailing: const Icon(Icons.chevron_right),
onTap: _showImageMaxSizeDialog,
),
ListTile(
leading: const Icon(Icons.tune),
title: const Text('Image compression'),
leading: Icon(Icons.tune),
title: Text(AppLocalizations.of(context)!.imageCompression),
subtitle: Text(
'$_imageCompression / 90 (higher = smaller file)',
),
@@ -1401,8 +1519,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
SwitchListTile(
secondary: const Icon(Icons.invert_colors),
title: const Text('Grayscale'),
secondary: Icon(Icons.invert_colors),
title: Text(AppLocalizations.of(context)!.grayscale),
subtitle: const Text(
'Converts image to grayscale for smaller file size',
),
@@ -1414,8 +1532,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
SwitchListTile(
secondary: const Icon(Icons.compress),
title: const Text('Ultra mode'),
secondary: Icon(Icons.compress),
title: Text(AppLocalizations.of(context)!.ultraMode),
subtitle: const Text(
'Extra-aggressive compression with stronger AVIF settings',
),
@@ -1433,10 +1551,50 @@ class _SettingsScreenState extends State<SettingsScreen> {
builder: (context, connectionProvider, child) =>
_buildImageModePreviewCard(connectionProvider),
),
_buildSectionHeader('Profiles'),
_buildSettingsCard([
SwitchListTile(
secondary: Icon(Icons.layers_outlined),
title: Text(AppLocalizations.of(context)!.enableProfiles),
subtitle: const Text(
'Show profile management UI while keeping the hidden Default profile as the current workspace.',
),
value: _profilesEnabled,
onChanged: (value) async {
await context
.read<ProfileWorkspaceCoordinator>()
.setProfilesEnabled(value);
if (!mounted) return;
setState(() {
_profilesEnabled = value;
});
},
),
if (_profilesEnabled)
ListTile(
leading: Icon(Icons.folder_copy_outlined),
title: Text(AppLocalizations.of(context)!.manageProfiles),
subtitle: Text(
context.watch<ProfileManager>().activeProfileId ==
ConfigProfile.defaultProfileId
? 'Default is active'
: 'Custom profile active',
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const ProfilesScreen(),
),
);
},
),
]),
_buildSectionHeader('Templates & Help'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.location_searching),
leading: Icon(Icons.location_searching),
title: Text(AppLocalizations.of(context)!.sarTemplates),
subtitle: Text(AppLocalizations.of(context)!.manageSarTemplates),
trailing: const Icon(Icons.chevron_right),
@@ -1450,7 +1608,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
},
),
ListTile(
leading: const Icon(Icons.school),
leading: Icon(Icons.school),
title: Text(AppLocalizations.of(context)!.viewWelcomeTutorial),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
@@ -1468,16 +1626,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
]),
if (!kIsWeb) ...[
_buildSectionHeader('Network Sharing'),
const ConnectionModeSelector(),
],
_buildSectionHeader(AppLocalizations.of(context)!.permissionsSection),
_buildSettingsCard([
SwitchListTile(
secondary: const Icon(Icons.gps_fixed),
title: const Text('Fast private GPS updates'),
secondary: Icon(Icons.gps_fixed),
title: Text(AppLocalizations.of(context)!.fastPrivateGpsUpdates),
subtitle: const Text(
'Use private zero-hop updates while moving significantly or while actively using map/messages.',
),
@@ -1485,8 +1638,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
onChanged: _setFastLocationUpdatesEnabled,
),
ListTile(
leading: const Icon(Icons.straighten),
title: const Text('Movement threshold'),
leading: Icon(Icons.straighten),
title: Text(AppLocalizations.of(context)!.movementThreshold),
subtitle: Text(
'${_fastLocationMovementThresholdMeters.toStringAsFixed(0)} m',
),
@@ -1494,14 +1647,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: _editFastLocationMovementThreshold,
),
ListTile(
leading: const Icon(Icons.timer),
title: const Text('Active-use update interval'),
leading: Icon(Icons.timer),
title: Text(AppLocalizations.of(context)!.activeuseUpdateInterval),
subtitle: Text('$_fastLocationActiveCadenceSeconds s'),
trailing: const Icon(Icons.chevron_right),
onTap: _editFastLocationActiveCadence,
),
ListTile(
leading: const Icon(Icons.location_on),
leading: Icon(Icons.location_on),
title: Text(AppLocalizations.of(context)!.locationPermission),
subtitle: FutureBuilder<LocationPermission>(
future: Geolocator.checkPermission(),
@@ -1554,7 +1707,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader(AppLocalizations.of(context)!.about),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.info),
leading: Icon(Icons.info),
title: Text(AppLocalizations.of(context)!.appVersion),
subtitle: Text(
_packageInfo != null
@@ -1564,12 +1717,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
onTap: _handleVersionTap,
),
ListTile(
leading: const Icon(Icons.badge),
leading: Icon(Icons.badge),
title: Text(AppLocalizations.of(context)!.appName),
subtitle: Text(_packageInfo?.appName ?? 'MeshCore SAR'),
),
ListTile(
leading: const Icon(Icons.description),
leading: Icon(Icons.description),
title: Text(AppLocalizations.of(context)!.aboutMeshCoreSar),
subtitle: Text(
AppLocalizations.of(context)!.aboutDescription.split('\n\n')[0],
@@ -1603,19 +1756,19 @@ class _SettingsScreenState extends State<SettingsScreen> {
_buildSectionHeader('Developer & Data'),
_buildSettingsCard([
ListTile(
leading: const Icon(Icons.bug_report),
leading: Icon(Icons.bug_report),
title: Text(AppLocalizations.of(context)!.packageName),
subtitle: Text(_packageInfo?.packageName ?? 'com.meshcore.sar'),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
padding: EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
AppLocalizations.of(context)!.sampleData,
style: Theme.of(context).textTheme.titleSmall,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Text(
AppLocalizations.of(context)!.sampleDataDescription,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
@@ -1638,7 +1791,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add_circle_outline),
: Icon(Icons.add_circle_outline),
label: Text(AppLocalizations.of(context)!.loadSampleData),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
@@ -1649,7 +1802,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Expanded(
child: OutlinedButton.icon(
onPressed: _isLoadingSampleData ? null : _clearSampleData,
icon: const Icon(Icons.delete_outline),
icon: Icon(Icons.delete_outline),
label: Text(AppLocalizations.of(context)!.clearAllData),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
@@ -1755,8 +1908,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
onPressed: _isPreviewLoading
? null
: _selectPreviewImageFromGallery,
icon: const Icon(Icons.photo_library_outlined),
label: const Text('Select from gallery'),
icon: Icon(Icons.photo_library_outlined),
label: Text(AppLocalizations.of(context)!.selectFromGallery),
),
],
),
@@ -1795,7 +1948,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
style: Theme.of(context).textTheme.bodySmall,
),
),
Switch.adaptive(
Switch(
value: _showCurrentImagePreview,
onChanged: (value) {
setState(() => _showCurrentImagePreview = value);
@@ -1962,21 +2115,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
Expanded(
child: _voiceStatChip(
label: 'Band-pass',
label: AppLocalizations.of(context)!.bandpass,
enabled: bandPassEnabled,
),
),
const SizedBox(width: 8),
SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Compressor',
label: AppLocalizations.of(context)!.compressor,
enabled: compressorEnabled,
),
),
const SizedBox(width: 8),
SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Limiter',
label: AppLocalizations.of(context)!.limiter,
enabled: limiterEnabled,
),
),
@@ -1987,21 +2140,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
Expanded(
child: _voiceStatChip(
label: 'Auto gain',
label: AppLocalizations.of(context)!.autoGain,
enabled: autoGainEnabled,
),
),
const SizedBox(width: 8),
SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Echo cancel',
label: AppLocalizations.of(context)!.echoCancel,
enabled: echoCancellationEnabled,
),
),
const SizedBox(width: 8),
SizedBox(width: 8),
Expanded(
child: _voiceStatChip(
label: 'Noise suppress',
label: AppLocalizations.of(context)!.noiseSuppress,
enabled: noiseSuppressionEnabled,
),
),
@@ -2012,7 +2165,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
children: [
Expanded(
child: _voiceStatChip(
label: 'Silence trim',
label: AppLocalizations.of(context)!.silenceTrim,
enabled: silenceTrimEnabled,
),
),
@@ -2084,7 +2237,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
subtitle: Text(AppLocalizations.of(context)!.blueDarkTheme),
value: AppThemeMode.dark,
),
const Divider(),
Divider(),
RadioListTile<AppThemeMode>(
title: Row(
children: [
@@ -2148,7 +2301,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
value: AppThemeMode.sarNavyBlue,
),
const Divider(),
Divider(),
RadioListTile<AppThemeMode>(
title: Text(AppLocalizations.of(context)!.autoSystem),
subtitle: Text(
@@ -2215,7 +2368,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Max image size'),
title: Text(AppLocalizations.of(context)!.maxImageSize),
content: SingleChildScrollView(
child: RadioGroup<int>(
groupValue: _imageMaxSize,
@@ -2231,7 +2384,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
value: size,
title: Text('$size×$size px'),
subtitle: size == ImagePreferences.defaultMaxSize
? const Text('Default')
? Text(AppLocalizations.of(context)!.defaultValue)
: null,
),
)
@@ -2253,7 +2406,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Voice bitrate'),
title: Text(AppLocalizations.of(context)!.voiceBitrate),
content: SingleChildScrollView(
child: RadioGroup<int>(
groupValue: _voiceBitrate,
@@ -2272,7 +2425,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
title: Text('$bitrate bps'),
subtitle:
bitrate == VoiceBitratePreferences.defaultBitrate
? const Text('Default')
? Text(AppLocalizations.of(context)!.defaultValue)
: null,
),
)
@@ -2311,16 +2464,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
'Version ${_packageInfo?.version ?? '1.0.0'}',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
SizedBox(height: 16),
Text(AppLocalizations.of(context)!.aboutDescription),
const SizedBox(height: 16),
SizedBox(height: 16),
Text(
AppLocalizations.of(context)!.technologiesUsed,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(height: 8),
Text(AppLocalizations.of(context)!.technologiesList),
],
),
@@ -2333,7 +2486,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
},
icon: const Icon(Icons.open_in_new),
icon: Icon(Icons.open_in_new),
label: Text(AppLocalizations.of(context)!.moreInfo),
),
TextButton(

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../models/device_info.dart';
import '../providers/connection_provider.dart';
import '../widgets/device/spectrum_scan_panel.dart';
import '../l10n/app_localizations.dart';
class SpectrumScanScreen extends StatefulWidget {
const SpectrumScanScreen({super.key});
@@ -323,8 +324,8 @@ class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
if (mergedCandidates.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Spectrum scan returned no candidate frequencies'),
SnackBar(
content: Text(AppLocalizations.of(context)!.spectrumScanReturnedNoCandidateFrequencies),
backgroundColor: Colors.red,
),
);
@@ -383,7 +384,7 @@ class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
: possibleBracketFrequencies;
return Scaffold(
appBar: AppBar(title: const Text('Spectrum Scan')),
appBar: AppBar(title: Text(AppLocalizations.of(context)!.spectrumScan)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [

View File

@@ -331,7 +331,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
if (_currentPage > 0)
TextButton.icon(
onPressed: _previousPage,
icon: const Icon(Icons.arrow_back),
icon: Icon(Icons.arrow_back),
label: Text(l10n.wizardBack),
)
else
@@ -510,7 +510,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
SizedBox(width: 12),
Expanded(
child: Text(
isConnected
@@ -601,7 +601,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
_selectedPreset.summary,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
SizedBox(height: 14),
...[
_FeatureItem(
icon: Icons.rule,

View File

@@ -0,0 +1,156 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../models/config_profile.dart';
import '../providers/app_provider.dart';
import 'image_preferences.dart';
import 'profiles_feature_service.dart';
import 'route_hash_preferences.dart';
import 'voice_bitrate_preferences.dart';
class AppConfigSnapshotService {
Future<AppSettingsProfileSection> capture(AppProvider appProvider) async {
final prefs = await SharedPreferences.getInstance();
final locationTracking = appProvider.locationTrackingService;
return AppSettingsProfileSection(
mapEnabled: appProvider.isMapEnabled,
contactsEnabled: appProvider.isContactsEnabled,
sensorsEnabled: appProvider.isSensorsEnabled,
voiceSilenceTrimmingEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
voiceBandPassFilterEnabled: appProvider.isVoiceBandPassFilterEnabled,
voiceCompressorEnabled: appProvider.isVoiceCompressorEnabled,
voiceLimiterEnabled: appProvider.isVoiceLimiterEnabled,
voiceAutoGainEnabled: appProvider.isVoiceAutoGainEnabled,
voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled,
voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled,
messageFontScale: appProvider.messageFontScale,
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
voiceBitrate: await VoiceBitratePreferences.getBitrate(),
routeHashSize: await RouteHashPreferences.getHashSize(),
imageMaxSize: await ImagePreferences.getMaxSize(),
imageCompression: await ImagePreferences.getCompression(),
imageGrayscale: await ImagePreferences.getGrayscale(),
imageUltraMode: await ImagePreferences.getUltraMode(),
showRxTxIndicators:
prefs.getBool(
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
) ??
true,
fastLocationUpdatesEnabled: locationTracking.fastLocationUpdatesEnabled,
fastLocationMovementThresholdMeters:
locationTracking.fastLocationMovementThresholdMeters,
fastLocationActiveCadenceSeconds:
locationTracking.fastLocationActiveCadenceSeconds,
);
}
Future<void> apply(
AppSettingsProfileSection? section,
AppProvider appProvider,
) async {
if (section == null || section.isEmpty) {
return;
}
if (section.mapEnabled != null) {
await appProvider.toggleMapEnabled(section.mapEnabled!);
}
if (section.contactsEnabled != null) {
await appProvider.toggleContactsEnabled(section.contactsEnabled!);
}
if (section.sensorsEnabled != null) {
await appProvider.toggleSensorsEnabled(section.sensorsEnabled!);
}
if (section.voiceSilenceTrimmingEnabled != null) {
await appProvider.toggleVoiceSilenceTrimmingEnabled(
section.voiceSilenceTrimmingEnabled!,
);
}
if (section.voiceBandPassFilterEnabled != null) {
await appProvider.toggleVoiceBandPassFilterEnabled(
section.voiceBandPassFilterEnabled!,
);
}
if (section.voiceCompressorEnabled != null) {
await appProvider.toggleVoiceCompressorEnabled(
section.voiceCompressorEnabled!,
);
}
if (section.voiceLimiterEnabled != null) {
await appProvider.toggleVoiceLimiterEnabled(section.voiceLimiterEnabled!);
}
if (section.voiceAutoGainEnabled != null) {
await appProvider.toggleVoiceAutoGainEnabled(
section.voiceAutoGainEnabled!,
);
}
if (section.voiceEchoCancellationEnabled != null) {
await appProvider.toggleVoiceEchoCancellationEnabled(
section.voiceEchoCancellationEnabled!,
);
}
if (section.voiceNoiseSuppressionEnabled != null) {
await appProvider.toggleVoiceNoiseSuppressionEnabled(
section.voiceNoiseSuppressionEnabled!,
);
}
if (section.messageFontScale != null) {
await appProvider.setMessageFontScale(section.messageFontScale!);
}
if (section.autoRouteRotationEnabled != null) {
await appProvider.toggleAutoRouteRotationEnabled(
section.autoRouteRotationEnabled!,
);
}
if (section.clearPathOnMaxRetry != null) {
await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!);
}
if (section.nearestRelayFallbackEnabled != null) {
await appProvider.toggleNearestRelayFallbackEnabled(
section.nearestRelayFallbackEnabled!,
);
}
if (section.voiceBitrate != null) {
await VoiceBitratePreferences.setBitrate(section.voiceBitrate!);
}
if (section.routeHashSize != null) {
await RouteHashPreferences.setHashSize(section.routeHashSize!);
}
if (section.imageMaxSize != null) {
await ImagePreferences.setMaxSize(section.imageMaxSize!);
}
if (section.imageCompression != null) {
await ImagePreferences.setCompression(section.imageCompression!);
}
if (section.imageGrayscale != null) {
await ImagePreferences.setGrayscale(section.imageGrayscale!);
}
if (section.imageUltraMode != null) {
await ImagePreferences.setUltraMode(section.imageUltraMode!);
}
if (section.showRxTxIndicators != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
section.showRxTxIndicators!,
);
}
final locationTracking = appProvider.locationTrackingService;
if (section.fastLocationUpdatesEnabled != null) {
locationTracking.fastLocationUpdatesEnabled =
section.fastLocationUpdatesEnabled!;
}
if (section.fastLocationMovementThresholdMeters != null) {
locationTracking.fastLocationMovementThresholdMeters =
section.fastLocationMovementThresholdMeters!;
}
if (section.fastLocationActiveCadenceSeconds != null) {
locationTracking.fastLocationActiveCadenceSeconds =
section.fastLocationActiveCadenceSeconds!;
}
await locationTracking.saveSettings();
await appProvider.reloadProfileScopedSettings();
}
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'profiles_feature_service.dart';
/// Background location tracking service for SAR operations
/// Tracks user location and sends periodic updates via MeshCore BLE
@@ -15,6 +16,11 @@ class BackgroundLocationService {
static const String _prefKeyLastLon = 'background_last_lon';
MeshCoreBleService? _bleService;
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
bool _isInitialized = false;
StreamSubscription<Position>? _positionSubscription;
@@ -71,8 +77,8 @@ class BackgroundLocationService {
// Save settings
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, true);
await prefs.setDouble(_prefKeyDistance, distanceThreshold);
await prefs.setBool(_scopedKey(_prefKeyEnabled), true);
await prefs.setDouble(_scopedKey(_prefKeyDistance), distanceThreshold);
// Start listening to position updates
Position? lastPosition;
@@ -110,8 +116,14 @@ class BackgroundLocationService {
lastPosition = position;
// Save to preferences
await prefs.setDouble(_prefKeyLastLat, position.latitude);
await prefs.setDouble(_prefKeyLastLon, position.longitude);
await prefs.setDouble(
_scopedKey(_prefKeyLastLat),
position.latitude,
);
await prefs.setDouble(
_scopedKey(_prefKeyLastLon),
position.longitude,
);
// Update device's advertised location
if (_bleService != null && _bleService!.isConnected) {
@@ -161,7 +173,7 @@ class BackgroundLocationService {
_positionSubscription = null;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, false);
await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
debugPrint('✅ [BackgroundLocation] Tracking stopped');
}
@@ -169,13 +181,13 @@ class BackgroundLocationService {
/// Note: This will restart tracking with the new threshold
Future<void> updateDistanceThreshold(double distance) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyDistance, distance);
await prefs.setDouble(_scopedKey(_prefKeyDistance), distance);
debugPrint(
'📏 [BackgroundLocation] Distance threshold updated to ${distance}m',
);
// Restart tracking if currently enabled
final isEnabled = prefs.getBool(_prefKeyEnabled) ?? false;
final isEnabled = prefs.getBool(_scopedKey(_prefKeyEnabled)) ?? false;
if (isEnabled && _bleService != null) {
await stopTracking();
await startTracking(distanceThreshold: distance);

View File

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

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import 'profiles_feature_service.dart';
import '../utils/key_comparison.dart';
import 'package:latlong2/latlong.dart';
@@ -10,10 +11,16 @@ import 'package:latlong2/latlong.dart';
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;
String _key(String baseKey, {String? namespace}) {
return ProfileStorageScope.scopedKey(baseKey, namespace: namespace);
}
/// Save contacts to persistent storage
Future<void> saveContacts(List<Contact> contacts) async {
Future<void> saveContacts(List<Contact> contacts, {String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -28,7 +35,10 @@ class ContactStorageService {
: jsonList;
final jsonString = jsonEncode(limitedList);
await prefs.setString(_contactsKey, jsonString);
await prefs.setString(
_key(_contactsKey, namespace: namespace),
jsonString,
);
debugPrint(
'✅ [ContactStorage] Saved ${limitedList.length} contacts to storage',
@@ -40,10 +50,15 @@ class ContactStorageService {
/// Load contacts from persistent storage
/// [excludePublicKey] - optional public key to exclude (e.g., device's own key)
Future<List<Contact>> loadContacts({Uint8List? excludePublicKey}) async {
Future<List<Contact>> loadContacts({
Uint8List? excludePublicKey,
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactsKey);
final jsonString = prefs.getString(
_key(_contactsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
debugPrint(' [ContactStorage] No stored contacts found');
@@ -82,23 +97,29 @@ class ContactStorageService {
}
/// Clear all stored contacts
Future<void> clearContacts() async {
Future<void> clearContacts({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_contactsKey);
await prefs.remove(_key(_contactsKey, namespace: namespace));
debugPrint('✅ [ContactStorage] Cleared all stored contacts');
} catch (e) {
debugPrint('❌ [ContactStorage] Error clearing contacts: $e');
}
}
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
Future<void> saveContactGroups(
List<SavedContactGroup> groups, {
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = jsonEncode(
groups.map((group) => _contactGroupToJson(group)).toList(),
);
await prefs.setString(_contactGroupsKey, jsonString);
await prefs.setString(
_key(_contactGroupsKey, namespace: namespace),
jsonString,
);
debugPrint(
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
);
@@ -107,10 +128,12 @@ class ContactStorageService {
}
}
Future<List<SavedContactGroup>> loadContactGroups() async {
Future<List<SavedContactGroup>> loadContactGroups({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactGroupsKey);
final jsonString = prefs.getString(
_key(_contactGroupsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return [];
}
@@ -126,11 +149,106 @@ class ContactStorageService {
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
Future<void> clearContactGroups({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactsKey);
await prefs.remove(_key(_contactGroupsKey, namespace: namespace));
debugPrint('✅ [ContactStorage] Cleared all contact groups');
} catch (e) {
debugPrint('❌ [ContactStorage] Error clearing contact groups: $e');
}
}
Future<void> savePendingAdverts(
List<Map<String, dynamic>> adverts, {
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
final limitedList = adverts.length > _maxStoredPendingAdverts
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
: adverts;
await prefs.setString(
_key(_pendingAdvertsKey, namespace: namespace),
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({
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(
_key(_pendingAdvertsKey, namespace: namespace),
);
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({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key(_pendingAdvertsKey, namespace: namespace));
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
} catch (e) {
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
}
}
// ── Favorites ───────────────────────────────────────────────────────────────
static const String _favoritesKey = 'contact_favorites';
Future<void> saveFavorites(
Set<String> publicKeyHexes, {
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_key(_favoritesKey, namespace: namespace),
publicKeyHexes.toList(),
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving favorites: $e');
}
}
Future<Set<String>> loadFavorites({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
final list = prefs.getStringList(
_key(_favoritesKey, namespace: namespace),
);
return list?.toSet() ?? {};
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading favorites: $e');
return {};
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(
_key(_contactsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
@@ -167,6 +285,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(),
};
}
@@ -190,6 +317,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');
@@ -242,6 +370,25 @@ class ContactStorageService {
}
}
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,

View File

@@ -0,0 +1,140 @@
import 'dart:typed_data';
import '../models/channel.dart';
import '../models/config_profile.dart';
import '../providers/channels_provider.dart';
import '../providers/connection_provider.dart';
class DeviceConfigApplicator {
ConfigProfileSections capture({
required ConnectionProvider connectionProvider,
required ChannelsProvider channelsProvider,
}) {
final deviceInfo = connectionProvider.deviceInfo;
return ConfigProfileSections(
deviceConfig: DeviceConfigProfileSection(
frequencyKhz: deviceInfo.radioFreq,
bandwidth: deviceInfo.radioBw,
spreadingFactor: deviceInfo.radioSf,
codingRate: deviceInfo.radioCr,
repeatEnabled: deviceInfo.clientRepeat,
txPower: deviceInfo.txPower,
telemetryModes: deviceInfo.telemetryModes,
advertLocationPolicy: deviceInfo.advertLocPolicy,
multiAcks: deviceInfo.multiAcks,
manualAddContacts: deviceInfo.manualAddContacts,
autoAddUsers: deviceInfo.autoAddUsers,
autoAddRepeaters: deviceInfo.autoAddRepeaters,
autoAddRoomServers: deviceInfo.autoAddRoomServers,
autoAddSensors: deviceInfo.autoAddSensors,
autoAddOverwriteOldest: deviceInfo.autoAddOverwriteOldest,
publicLatitude: _decodeAdvertCoordinate(deviceInfo.advLat),
publicLongitude: _decodeAdvertCoordinate(deviceInfo.advLon),
),
channels: channelsProvider.channels,
);
}
Future<void> apply(
ConfigProfile profile, {
required ConnectionProvider connectionProvider,
required ChannelsProvider channelsProvider,
}) async {
if (!connectionProvider.deviceInfo.isConnected) {
return;
}
final deviceConfig = profile.sections.deviceConfig;
if (deviceConfig != null && !deviceConfig.isEmpty) {
if (deviceConfig.txPower != null) {
await connectionProvider.setTxPower(deviceConfig.txPower!);
}
if (deviceConfig.telemetryModes != null ||
deviceConfig.advertLocationPolicy != null ||
deviceConfig.manualAddContacts != null ||
deviceConfig.multiAcks != null) {
await connectionProvider.setOtherParams(
manualAddContacts: (deviceConfig.manualAddContacts ?? false) ? 1 : 0,
telemetryModes: deviceConfig.telemetryModes ?? 0,
advertLocationPolicy: deviceConfig.advertLocationPolicy ?? 0,
multiAcks: deviceConfig.multiAcks ?? 0,
);
}
if (deviceConfig.autoAddUsers != null &&
deviceConfig.autoAddRepeaters != null &&
deviceConfig.autoAddRoomServers != null &&
deviceConfig.autoAddSensors != null &&
deviceConfig.autoAddOverwriteOldest != null) {
await connectionProvider.setAutoaddConfig(
autoAddUsers: deviceConfig.autoAddUsers!,
autoAddRepeaters: deviceConfig.autoAddRepeaters!,
autoAddRoomServers: deviceConfig.autoAddRoomServers!,
autoAddSensors: deviceConfig.autoAddSensors!,
overwriteOldest: deviceConfig.autoAddOverwriteOldest!,
);
}
if (deviceConfig.publicLatitude != null &&
deviceConfig.publicLongitude != null) {
await connectionProvider.setAdvertLatLon(
latitude: deviceConfig.publicLatitude!,
longitude: deviceConfig.publicLongitude!,
);
}
if (deviceConfig.frequencyKhz != null &&
deviceConfig.bandwidth != null &&
deviceConfig.spreadingFactor != null &&
deviceConfig.codingRate != null) {
await connectionProvider.setRadioParams(
frequency: deviceConfig.frequencyKhz!,
bandwidth: deviceConfig.bandwidth!,
spreadingFactor: deviceConfig.spreadingFactor!,
codingRate: deviceConfig.codingRate!,
repeat: deviceConfig.repeatEnabled,
);
}
}
await _applyChannels(
channels: profile.sections.channels,
connectionProvider: connectionProvider,
channelsProvider: channelsProvider,
);
await connectionProvider.refreshDeviceInfo();
}
Future<void> _applyChannels({
required List<Channel> channels,
required ConnectionProvider connectionProvider,
required ChannelsProvider channelsProvider,
}) async {
if (channels.isEmpty) {
return;
}
final desired = {for (final channel in channels) channel.index: channel};
for (final channel in channels) {
await connectionProvider.setChannelSlot(
channelIdx: channel.index,
channelName: channel.name,
secret: Uint8List.fromList(channel.secret),
);
channelsProvider.addOrUpdateChannelObject(channel);
}
for (final existing in channelsProvider.channels) {
if (existing.index == 0 || desired.containsKey(existing.index)) {
continue;
}
await connectionProvider.deleteChannel(existing.index);
channelsProvider.removeChannel(existing.index);
}
}
double? _decodeAdvertCoordinate(int? value) {
if (value == null || value == 0) {
return null;
}
return value / 1e6;
}
}

View File

@@ -1,4 +1,5 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
/// Stores user-selected image compression settings.
class ImagePreferences {
@@ -17,44 +18,53 @@ class ImagePreferences {
static Future<int> getMaxSize() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_maxSizeKey) ?? defaultMaxSize;
final value =
prefs.getInt(ProfileStorageScope.scopedKey(_maxSizeKey)) ??
defaultMaxSize;
return supportedSizes.contains(value) ? value : defaultMaxSize;
}
static Future<void> setMaxSize(int size) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_maxSizeKey, size);
await prefs.setInt(ProfileStorageScope.scopedKey(_maxSizeKey), size);
}
static Future<int> getCompression() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_qualityKey) ?? defaultQuality;
final value =
prefs.getInt(ProfileStorageScope.scopedKey(_qualityKey)) ??
defaultQuality;
return value.clamp(10, 90);
}
static Future<void> setCompression(int compression) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_qualityKey, compression.clamp(10, 90));
await prefs.setInt(
ProfileStorageScope.scopedKey(_qualityKey),
compression.clamp(10, 90),
);
}
static Future<bool> getGrayscale() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_grayscaleKey) ?? defaultGrayscale;
return prefs.getBool(ProfileStorageScope.scopedKey(_grayscaleKey)) ??
defaultGrayscale;
}
static Future<void> setGrayscale(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_grayscaleKey, value);
await prefs.setBool(ProfileStorageScope.scopedKey(_grayscaleKey), value);
}
static Future<bool> getUltraMode() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_ultraModeKey) ?? defaultUltraMode;
return prefs.getBool(ProfileStorageScope.scopedKey(_ultraModeKey)) ??
defaultUltraMode;
}
static Future<void> setUltraMode(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_ultraModeKey, value);
await prefs.setBool(ProfileStorageScope.scopedKey(_ultraModeKey), value);
}
static int effectiveMaxSize(

View File

@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'profiles_feature_service.dart';
/// Centralized location tracking service for MeshCore SAR
///
@@ -49,6 +50,10 @@ class LocationTrackingService {
static const String _prefKeyFastActiveCadence =
'fast_location_active_cadence_seconds';
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
// ============================================================================
// Configuration Properties
// ============================================================================
@@ -399,7 +404,7 @@ class LocationTrackingService {
// Save disabled state
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, false);
await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
debugPrint('✅ [LocationTracking] Tracking stopped');
}
@@ -477,8 +482,8 @@ class LocationTrackingService {
// Save to preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyLastLat, position.latitude);
await prefs.setDouble(_prefKeyLastLon, position.longitude);
await prefs.setDouble(_scopedKey(_prefKeyLastLat), position.latitude);
await prefs.setDouble(_scopedKey(_prefKeyLastLon), position.longitude);
debugPrint('✅ [LocationTracking] Initial position set without broadcast');
debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s');
@@ -646,17 +651,24 @@ class LocationTrackingService {
Future<void> loadSettings() async {
final prefs = await SharedPreferences.getInstance();
minDistanceMeters = prefs.getDouble(_prefKeyMinDistance) ?? 5.0;
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
minDistanceMeters = prefs.getDouble(_scopedKey(_prefKeyMinDistance)) ?? 5.0;
maxDistanceMeters =
prefs.getDouble(_scopedKey(_prefKeyMaxDistance)) ?? 100.0;
minTimeIntervalSeconds =
prefs.getInt(_scopedKey(_prefKeyMinTimeInterval)) ?? 30;
gpsUpdateDistance =
prefs.getDouble(_scopedKey(_prefKeyGpsUpdateDistance)) ?? 10.0;
fastLocationUpdatesEnabled =
prefs.getBool(_prefKeyFastLocationEnabled) ?? false;
prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false;
fastLocationMovementThresholdMeters =
(prefs.getDouble(_prefKeyFastMovementThreshold) ?? gpsUpdateDistance)
(prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ??
gpsUpdateDistance)
.clamp(1.0, 1000.0);
fastLocationActiveCadenceSeconds =
(prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60);
(prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ?? 10).clamp(
5,
60,
);
debugPrint('✅ [LocationTracking] Settings loaded');
debugPrint(' Min distance: ${minDistanceMeters}m');
@@ -674,21 +686,27 @@ class LocationTrackingService {
Future<void> saveSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_prefKeyMinDistance, minDistanceMeters);
await prefs.setDouble(_prefKeyMaxDistance, maxDistanceMeters);
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
await prefs.setBool(_prefKeyEnabled, isTracking);
await prefs.setDouble(_scopedKey(_prefKeyMinDistance), minDistanceMeters);
await prefs.setDouble(_scopedKey(_prefKeyMaxDistance), maxDistanceMeters);
await prefs.setInt(
_scopedKey(_prefKeyMinTimeInterval),
minTimeIntervalSeconds,
);
await prefs.setDouble(
_scopedKey(_prefKeyGpsUpdateDistance),
gpsUpdateDistance,
);
await prefs.setBool(_scopedKey(_prefKeyEnabled), isTracking);
await prefs.setBool(
_prefKeyFastLocationEnabled,
_scopedKey(_prefKeyFastLocationEnabled),
fastLocationUpdatesEnabled,
);
await prefs.setDouble(
_prefKeyFastMovementThreshold,
_scopedKey(_prefKeyFastMovementThreshold),
fastLocationMovementThresholdMeters,
);
await prefs.setInt(
_prefKeyFastActiveCadence,
_scopedKey(_prefKeyFastActiveCadence),
fastLocationActiveCadenceSeconds,
);

View File

@@ -41,90 +41,103 @@ class MapMarkerService {
Function(Contact)? onTap,
double mapRotation = 0,
Position? userPosition,
Map<String, LatLng>? estimatedLocations,
}) {
return contacts.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return contacts
.map((contact) {
final location = contact.displayLocation ??
estimatedLocations?[contact.publicKeyHex];
if (location == null) return null;
return Marker(
point: location,
width: 80,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * pi / 180,
child: GestureDetector(
onTap: onTap != null ? () => onTap(contact) : null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Location update time indicator
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: getLocationAgeColor(contact),
borderRadius: BorderRadius.circular(3),
),
child: Text(
contact.timeSinceLocationUpdate,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 2),
// Marker icon
Container(
decoration: BoxDecoration(
color: Colors.white,
shape: contact.type == ContactType.channel ||
contact.type == ContactType.room
? BoxShape.rectangle
: BoxShape.circle,
borderRadius: contact.type == ContactType.channel ||
contact.type == ContactType.room
? BorderRadius.circular(14)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
return Marker(
point: location,
width: 80,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * pi / 180,
child: GestureDetector(
onTap: onTap != null ? () => onTap(contact) : null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Location update time indicator
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: getLocationAgeColor(contact),
borderRadius: BorderRadius.circular(3),
),
child: Text(
contact.timeSinceLocationUpdate,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
],
),
padding: const EdgeInsets.all(2),
child: ContactAvatar(contact: contact, radius: 16),
),
const SizedBox(height: 2),
// Name label (without emoji)
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(
contact.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
),
const SizedBox(height: 2),
// Marker icon
Container(
decoration: BoxDecoration(
color: Colors.white,
shape:
contact.type == ContactType.channel ||
contact.type == ContactType.room
? BoxShape.rectangle
: BoxShape.circle,
borderRadius:
contact.type == ContactType.channel ||
contact.type == ContactType.room
? BorderRadius.circular(14)
: null,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(2),
child: ContactAvatar(contact: contact, radius: 16),
),
const SizedBox(height: 2),
// Name label (without emoji)
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(
contact.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
),
),
],
),
],
),
),
),
),
);
}).whereType<Marker>().toList();
);
})
.whereType<Marker>()
.toList();
}
/// Generate markers for SAR events.
@@ -141,10 +154,11 @@ class MapMarkerService {
required BuildContext context,
Function(SarMarker)? onTap,
double mapRotation = 0,
LatLng Function(LatLng point)? pointTransformer,
}) {
return sarMarkers.map((marker) {
return Marker(
point: marker.location,
point: pointTransformer?.call(marker.location) ?? marker.location,
width: 90,
height: 100,
rotate: false, // Don't rotate the entire marker with map
@@ -157,7 +171,10 @@ class MapMarkerService {
children: [
// Time ago label
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: getSarMarkerColor(marker.type),
borderRadius: BorderRadius.circular(3),
@@ -188,7 +205,7 @@ class MapMarkerService {
),
padding: const EdgeInsets.all(6),
child: Text(
marker.emoji, // Use custom emoji if available
marker.emoji, // Use custom emoji if available
style: const TextStyle(fontSize: 18),
),
),
@@ -196,13 +213,17 @@ class MapMarkerService {
// Type label
Container(
constraints: const BoxConstraints(maxWidth: 90),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Text(
marker.displayName, // Uses notes if available, otherwise type.displayName
marker
.displayName, // Uses notes if available, otherwise type.displayName
style: const TextStyle(
color: Colors.white,
fontSize: 9,
@@ -269,7 +290,8 @@ class MapMarkerService {
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a = sin(dLat / 2) * sin(dLat / 2) +
final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
@@ -299,8 +321,8 @@ class MapMarkerService {
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) -
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final x =
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
@@ -368,6 +390,8 @@ class MapMarkerService {
return Colors.deepPurple; // Purple for repeaters
case ContactType.room:
return Colors.teal; // Teal for rooms
case ContactType.sensor:
return Colors.green; // Green for sensors
case ContactType.channel:
return Colors.orange; // Orange for channels
case ContactType.none:
@@ -389,6 +413,8 @@ class MapMarkerService {
return Icons.router; // Router icon for repeaters
case ContactType.room:
return Icons.forum; // Forum/chat icon for rooms
case ContactType.sensor:
return Icons.sensors; // Sensors icon for sensor nodes
case ContactType.channel:
return Icons.public; // Public icon for channels
case ContactType.none:
@@ -466,7 +492,8 @@ class MapMarkerService {
}
if (allPoints.isEmpty) {
return defaultCenter ?? const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
return defaultCenter ??
const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
}
double lat = 0, lng = 0;

View File

@@ -0,0 +1,100 @@
import 'package:shared_preferences/shared_preferences.dart';
import '../models/config_profile.dart';
import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart';
import 'profiles_feature_service.dart';
class MapWorkspaceSnapshotService {
Future<MapWorkspaceProfileSection> capture({
required MapProvider mapProvider,
required DrawingProvider drawingProvider,
}) async {
final prefs = await SharedPreferences.getInstance();
return MapWorkspaceProfileSection(
mapPrefs: {
'map_rotate_with_heading':
prefs.getBool(
ProfileStorageScope.scopedKey('map_rotate_with_heading'),
) ??
false,
'map_show_debug_info':
prefs.getBool(
ProfileStorageScope.scopedKey('map_show_debug_info'),
) ??
false,
'map_fullscreen':
prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ??
false,
'map_last_latitude': prefs.getDouble(
ProfileStorageScope.scopedKey('map_last_latitude'),
),
'map_last_longitude': prefs.getDouble(
ProfileStorageScope.scopedKey('map_last_longitude'),
),
'map_last_zoom': prefs.getDouble(
ProfileStorageScope.scopedKey('map_last_zoom'),
),
'map_last_layer_type': prefs.getInt(
ProfileStorageScope.scopedKey('map_last_layer_type'),
),
'map_gps_update_distance': prefs.getDouble(
ProfileStorageScope.scopedKey('map_gps_update_distance'),
),
'background_tracking_enabled': prefs.getBool(
ProfileStorageScope.scopedKey('background_tracking_enabled'),
),
},
drawings: drawingProvider.exportDrawingsJson(),
currentTrail: mapProvider.currentTrail?.toJson(),
trailHistory: mapProvider.trailHistory
.map((trail) => trail.toJson())
.toList(),
importedTrail: mapProvider.importedTrail?.toJson(),
isTrailVisible: mapProvider.isTrailVisible,
showCadastralOverlay: mapProvider.showCadastralOverlay,
showForestRoadsOverlay: mapProvider.showForestRoadsOverlay,
showHikingTrailsOverlay: mapProvider.showHikingTrailsOverlay,
showMainRoadsOverlay: mapProvider.showMainRoadsOverlay,
showHouseNumbersOverlay: mapProvider.showHouseNumbersOverlay,
showFireHazardZonesOverlay: mapProvider.showFireHazardZonesOverlay,
showHistoricalFiresOverlay: mapProvider.showHistoricalFiresOverlay,
showFirebreaksOverlay: mapProvider.showFirebreaksOverlay,
showKrasFireZonesOverlay: mapProvider.showKrasFireZonesOverlay,
showPlaceNamesOverlay: mapProvider.showPlaceNamesOverlay,
showMunicipalityBordersOverlay:
mapProvider.showMunicipalityBordersOverlay,
showAllContactTrails: mapProvider.showAllContactTrails,
hideRepeatersOnMap: mapProvider.hideRepeatersOnMap,
);
}
Future<void> apply(
MapWorkspaceProfileSection? section, {
required MapProvider mapProvider,
required DrawingProvider drawingProvider,
}) async {
if (section == null || section.isEmpty) {
return;
}
final prefs = await SharedPreferences.getInstance();
final mapPrefs = section.mapPrefs ?? const <String, dynamic>{};
for (final entry in mapPrefs.entries) {
final key = ProfileStorageScope.scopedKey(entry.key);
final value = entry.value;
if (value == null) {
await prefs.remove(key);
} else if (value is bool) {
await prefs.setBool(key, value);
} else if (value is int) {
await prefs.setInt(key, value);
} else if (value is double) {
await prefs.setDouble(key, value);
}
}
mapProvider.applyWorkspaceJson(section.toJson());
await drawingProvider.replaceDrawingsFromJson(section.drawings);
}
}

View File

@@ -20,6 +20,14 @@ class MeshMapNode {
required this.updatedAtMs,
});
bool get hasValidCoordinates =>
latitude >= -90 &&
latitude <= 90 &&
longitude >= -180 &&
longitude <= 180 &&
latitude != 0 &&
longitude != 0;
factory MeshMapNode.fromJson(Map<String, dynamic> json) {
return MeshMapNode(
type: (json['type'] as num?)?.toInt() ?? 0,
@@ -77,9 +85,7 @@ class MeshMapNodesService {
final nodes = nodesRaw
.whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson)
.where(
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
)
.where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates)
.toList();
await _storeCache(nodes, cachedAt: now);
@@ -116,9 +122,7 @@ class MeshMapNodesService {
final nodes = decoded
.whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson)
.where(
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
)
.where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates)
.toList();
_cachedNodes = nodes;
_cachedAt = cachedAt;

View File

@@ -6,6 +6,7 @@ import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import '../models/message_transfer_details.dart';
import '../models/message_route_metadata.dart';
import 'profiles_feature_service.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage
@@ -24,6 +25,10 @@ class MessageStorageService {
static const String _legacyPathBytesKey = 'storedPathBytes';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
String _key(String baseKey, {String? namespace}) {
return ProfileStorageScope.scopedKey(baseKey, namespace: namespace);
}
/// Save messages to persistent storage
Future<void> saveMessages(
List<Message> messages, {
@@ -31,6 +36,7 @@ class MessageStorageService {
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
Map<String, MessageTransferDetails> messageTransferDetails = const {},
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -52,7 +58,10 @@ class MessageStorageService {
: jsonList;
final jsonString = jsonEncode(limitedList);
await prefs.setString(_messagesKey, jsonString);
await prefs.setString(
_key(_messagesKey, namespace: namespace),
jsonString,
);
final retainedMessageIds = limitedList
.map((entry) => entry['id'] as String)
.toSet();
@@ -81,19 +90,19 @@ class MessageStorageService {
}
}
await prefs.setString(
_messageContactLocationsKey,
_key(_messageContactLocationsKey, namespace: namespace),
jsonEncode(locationJson),
);
await prefs.setString(
_messageReceptionDetailsKey,
_key(_messageReceptionDetailsKey, namespace: namespace),
jsonEncode(receptionJson),
);
await prefs.setString(
_messageTransferDetailsKey,
_key(_messageTransferDetailsKey, namespace: namespace),
jsonEncode(transferJson),
);
await prefs.setString(
_messageRouteMetadataKey,
_key(_messageRouteMetadataKey, namespace: namespace),
jsonEncode(routeMetadataJson),
);
@@ -105,11 +114,14 @@ class MessageStorageService {
}
}
Future<Map<String, MessageContactLocation>>
loadMessageContactLocations() async {
Future<Map<String, MessageContactLocation>> loadMessageContactLocations({
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageContactLocationsKey);
final jsonString = prefs.getString(
_key(_messageContactLocationsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
@@ -135,11 +147,14 @@ class MessageStorageService {
}
}
Future<Map<String, MessageReceptionDetails>>
loadMessageReceptionDetails() async {
Future<Map<String, MessageReceptionDetails>> loadMessageReceptionDetails({
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageReceptionDetailsKey);
final jsonString = prefs.getString(
_key(_messageReceptionDetailsKey, namespace: namespace),
);
final result = <String, MessageReceptionDetails>{};
if (jsonString != null && jsonString.isNotEmpty) {
final decoded = jsonDecode(jsonString);
@@ -155,12 +170,16 @@ class MessageStorageService {
}
}
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails(
namespace: namespace,
);
embeddedReceptionDetails.forEach((messageId, snapshot) {
result.putIfAbsent(messageId, () => snapshot);
});
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages(
namespace: namespace,
);
fallbackPathBytes.forEach((messageId, pathBytes) {
result.putIfAbsent(
messageId,
@@ -177,11 +196,14 @@ class MessageStorageService {
}
}
Future<Map<String, MessageTransferDetails>>
loadMessageTransferDetails() async {
Future<Map<String, MessageTransferDetails>> loadMessageTransferDetails({
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageTransferDetailsKey);
final jsonString = prefs.getString(
_key(_messageTransferDetailsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
@@ -207,10 +229,14 @@ class MessageStorageService {
}
}
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async {
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata({
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageRouteMetadataKey);
final jsonString = prefs.getString(
_key(_messageRouteMetadataKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
@@ -234,10 +260,12 @@ class MessageStorageService {
}
/// Load messages from persistent storage
Future<List<Message>> loadMessages() async {
Future<List<Message>> loadMessages({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
final jsonString = prefs.getString(
_key(_messagesKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
debugPrint(' [MessageStorage] No stored messages found');
@@ -262,25 +290,35 @@ class MessageStorageService {
}
/// Clear all stored messages
Future<void> clearMessages() async {
Future<void> clearMessages({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_messagesKey);
await prefs.remove(_messageContactLocationsKey);
await prefs.remove(_messageReceptionDetailsKey);
await prefs.remove(_messageTransferDetailsKey);
await prefs.remove(_messageRouteMetadataKey);
await prefs.remove(_removedSarMarkerIdsKey);
await prefs.remove(_key(_messagesKey, namespace: namespace));
await prefs.remove(
_key(_messageContactLocationsKey, namespace: namespace),
);
await prefs.remove(
_key(_messageReceptionDetailsKey, namespace: namespace),
);
await prefs.remove(
_key(_messageTransferDetailsKey, namespace: namespace),
);
await prefs.remove(_key(_messageRouteMetadataKey, namespace: namespace));
await prefs.remove(_key(_removedSarMarkerIdsKey, namespace: namespace));
debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
}
}
Future<Set<String>> loadRemovedSarMarkerIds() async {
Future<Set<String>> loadRemovedSarMarkerIds({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const [];
final ids =
prefs.getStringList(
_key(_removedSarMarkerIdsKey, namespace: namespace),
) ??
const [];
return ids.toSet();
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
@@ -288,10 +326,16 @@ class MessageStorageService {
}
}
Future<void> saveRemovedSarMarkerIds(Set<String> ids) async {
Future<void> saveRemovedSarMarkerIds(
Set<String> ids, {
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort());
await prefs.setStringList(
_key(_removedSarMarkerIdsKey, namespace: namespace),
ids.toList()..sort(),
);
debugPrint(
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
);
@@ -301,10 +345,12 @@ class MessageStorageService {
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
final jsonString = prefs.getString(
_key(_messagesKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
@@ -329,6 +375,8 @@ class MessageStorageService {
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
final persistedPathBytes =
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
return {
'id': message.id,
'messageType': message.messageType.name,
@@ -337,12 +385,16 @@ class MessageStorageService {
: null,
'channelIdx': message.channelIdx,
'pathLen': message.pathLen,
'pathBytes': persistedPathBytes?.toList(),
'textType': message.textType.value,
'senderTimestamp': message.senderTimestamp,
'text': message.text,
'isSarMarker': message.isSarMarker,
'sarGpsLat': message.sarGpsCoordinates?.latitude,
'sarGpsLon': message.sarGpsCoordinates?.longitude,
'sarCustomMapLat': message.sarCustomMapPoint?.latitude,
'sarCustomMapLon': message.sarCustomMapPoint?.longitude,
'sarCustomMapId': message.sarCustomMapId,
'sarNotes': message.sarNotes,
'sarCustomEmoji': message.sarCustomEmoji,
'sarColorIndex': message.sarColorIndex,
@@ -395,10 +447,13 @@ class MessageStorageService {
};
}
Future<Map<String, MessageReceptionDetails>>
_loadEmbeddedReceptionDetails() async {
Future<Map<String, MessageReceptionDetails>> _loadEmbeddedReceptionDetails({
String? namespace,
}) async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
final jsonString = prefs.getString(
_key(_messagesKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
@@ -421,9 +476,13 @@ class MessageStorageService {
return result;
}
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages({
String? namespace,
}) async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
final jsonString = prefs.getString(
_key(_messagesKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
@@ -465,14 +524,33 @@ 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,
isSarMarker: json['isSarMarker'] as bool? ?? false,
sarGpsCoordinates:
json['sarGpsLat'] != null && json['sarGpsLon'] != null
? LatLng(json['sarGpsLat'] as double, json['sarGpsLon'] as double)
? LatLng(
(json['sarGpsLat'] as num).toDouble(),
(json['sarGpsLon'] as num).toDouble(),
)
: null,
sarCustomMapPoint:
json['sarCustomMapLat'] != null && json['sarCustomMapLon'] != null
? LatLng(
(json['sarCustomMapLat'] as num).toDouble(),
(json['sarCustomMapLon'] as num).toDouble(),
)
: null,
sarCustomMapId: json['sarCustomMapId'] as String?,
sarNotes: json['sarNotes'] as String?,
sarCustomEmoji: json['sarCustomEmoji'] as String?,
sarColorIndex: json['sarColorIndex'] as int?,
@@ -562,4 +640,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,4 +1,5 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
class MessagingRoutePreferences {
static const bool defaultAutoRouteRotationEnabled = false;
@@ -14,33 +15,49 @@ class MessagingRoutePreferences {
static Future<bool> getAutoRouteRotationEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_autoRouteRotationKey) ??
return prefs.getBool(
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
) ??
defaultAutoRouteRotationEnabled;
}
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_autoRouteRotationKey, enabled);
await prefs.setBool(
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
enabled,
);
}
static Future<bool> getClearPathOnMaxRetry() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry;
return prefs.getBool(
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
) ??
defaultClearPathOnMaxRetry;
}
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_clearPathOnMaxRetryKey, enabled);
await prefs.setBool(
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
enabled,
);
}
static Future<bool> getNearestRelayFallbackEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_nearestRelayFallbackKey) ??
return prefs.getBool(
ProfileStorageScope.scopedKey(_nearestRelayFallbackKey),
) ??
defaultNearestRelayFallbackEnabled;
}
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_nearestRelayFallbackKey, enabled);
await prefs.setBool(
ProfileStorageScope.scopedKey(_nearestRelayFallbackKey),
enabled,
);
}
}

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,28 @@ class NotificationService {
final FlutterLocalNotificationsPlugin _notificationsPlugin =
FlutterLocalNotificationsPlugin();
static const String _prefMessagesEnabled = 'notifications_messages_enabled';
static const String _prefSarEnabled = 'notifications_sar_enabled';
static const String _prefDiscoveryEnabled = 'notifications_discovery_enabled';
static const String _prefUpdatesEnabled = 'notifications_updates_enabled';
static const String _prefMuteForeground = 'notifications_mute_foreground';
bool _isInitialized = false;
bool _permissionGranted = false;
bool _messageNotificationsEnabled = true;
bool _sarNotificationsEnabled = true;
bool _discoveryNotificationsEnabled = true;
bool _updateNotificationsEnabled = true;
bool _muteForegroundNotifications = true;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _launchPayload;
// Notification IDs
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 +53,32 @@ 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 discoveryNotificationsEnabled => _discoveryNotificationsEnabled;
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 +107,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 +174,54 @@ class NotificationService {
}
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
_messageNotificationsEnabled = prefs.getBool(_prefMessagesEnabled) ?? true;
_sarNotificationsEnabled = prefs.getBool(_prefSarEnabled) ?? true;
_discoveryNotificationsEnabled =
prefs.getBool(_prefDiscoveryEnabled) ?? true;
_updateNotificationsEnabled = prefs.getBool(_prefUpdatesEnabled) ?? true;
_muteForegroundNotifications = prefs.getBool(_prefMuteForeground) ?? true;
}
Future<void> setMessageNotificationsEnabled(bool value) async {
_messageNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefMessagesEnabled, value);
}
Future<void> setSarNotificationsEnabled(bool value) async {
_sarNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefSarEnabled, value);
}
Future<void> setDiscoveryNotificationsEnabled(bool value) async {
_discoveryNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefDiscoveryEnabled, value);
}
Future<void> setUpdateNotificationsEnabled(bool value) async {
_updateNotificationsEnabled = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefUpdatesEnabled, value);
}
Future<void> setMuteForegroundNotifications(bool value) async {
_muteForegroundNotifications = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefMuteForeground, value);
}
void handleAppLifecycleStateChanged(AppLifecycleState state) {
_lifecycleState = state;
}
bool _shouldSuppressForegroundNotifications() {
return _muteForegroundNotifications && isAppInForeground;
}
/// Create notification channels for Android
Future<void> _createNotificationChannels() async {
try {
@@ -177,9 +267,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 +313,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 +340,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 +522,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
@@ -547,6 +685,16 @@ 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
@@ -617,4 +765,169 @@ class NotificationService {
);
}
}
Future<bool> showLowBatteryNotification({
required String nodeId,
required String nodeName,
required double batteryPercent,
required bool isCurrentDevice,
}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [NotificationService] Not initialized, skipping notification',
);
return false;
}
if (!_permissionGranted) {
debugPrint(
'⚠️ [NotificationService] Permission not granted, skipping notification',
);
return false;
}
if (!_messageNotificationsEnabled) {
debugPrint(' [NotificationService] Message notifications disabled');
return false;
}
if (_shouldSuppressForegroundNotifications()) {
debugPrint(
' [NotificationService] App in foreground, skipping low battery notification',
);
return false;
}
final roundedPercent = batteryPercent.round().clamp(0, 100);
final title = isCurrentDevice
? 'Device battery low'
: 'Contact battery low';
final body = isCurrentDevice
? '$nodeName battery is at $roundedPercent%.'
: '$nodeName is at $roundedPercent% battery.';
final notificationId =
_batteryNotificationId + ((nodeId.hashCode & 0x7fffffff) % 1000);
try {
final androidDetails = AndroidNotificationDetails(
_batteryChannelId,
_batteryChannelName,
channelDescription: _batteryChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
styleInformation: BigTextStyleInformation(
body,
contentTitle: title,
summaryText: '$roundedPercent%',
),
);
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: 'battery_alerts',
subtitle: '$roundedPercent%',
);
final notificationDetails = NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
);
await _notificationsPlugin.show(
id: notificationId,
title: title,
body: body,
notificationDetails: notificationDetails,
payload: 'battery:$nodeId',
);
debugPrint(
'✅ [NotificationService] Showed low battery notification for $nodeName ($roundedPercent%)',
);
return true;
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing low battery notification: $e',
);
return false;
}
}
Future<bool> showContactDiscoveredNotification({
required String contactKey,
String? contactName,
}) async {
if (!_isInitialized) return false;
if (!_permissionGranted) return false;
if (!_discoveryNotificationsEnabled) return false;
if (_shouldSuppressForegroundNotifications()) return false;
final shortKey = contactKey.length > 12
? contactKey.substring(0, 12).toUpperCase()
: contactKey.toUpperCase();
final title = 'New contact discovered';
final resolvedName = contactName?.trim();
final body = resolvedName != null && resolvedName.isNotEmpty
? '$resolvedName is available in Discovery.'
: 'New contact $shortKey is available in Discovery.';
final notificationId =
_discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000);
try {
final androidDetails = AndroidNotificationDetails(
_discoveryChannelId,
_discoveryChannelName,
channelDescription: _discoveryChannelDescription,
importance: Importance.high,
priority: Priority.high,
ticker: title,
playSound: true,
enableVibration: true,
showWhen: true,
when: DateTime.now().millisecondsSinceEpoch,
);
final darwinDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
sound: 'default',
threadIdentifier: 'discovery_alerts',
);
await _notificationsPlugin.show(
id: notificationId,
title: title,
body: body,
notificationDetails: NotificationDetails(
android: androidDetails,
iOS: darwinDetails,
),
payload: 'discovery:$contactKey',
);
return true;
} catch (e) {
debugPrint(
'❌ [NotificationService] Error showing discovery notification: $e',
);
return false;
}
}
}
class _LifecycleObserver with WidgetsBindingObserver {
_LifecycleObserver(this._service);
final NotificationService _service;
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_service.handleAppLifecycleStateChanged(state);
}
}

View File

@@ -0,0 +1,21 @@
import '../models/device_info.dart';
class ProfileDeviceKeyResolver {
static String? resolve({
required DeviceInfo deviceInfo,
required ConnectionMode connectionMode,
}) {
// Always prefer the public key — it's the only truly stable identifier.
// Don't fall back to deviceId to avoid creating duplicate profiles when
// publicKey arrives late (after initial connection but before DeviceInfo).
final publicKey = deviceInfo.publicKey;
if (publicKey == null || publicKey.isEmpty) {
return null; // Wait until publicKey is available
}
final hex = publicKey
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return hex.isNotEmpty ? 'pk:$hex' : null;
}
}

View File

@@ -0,0 +1,185 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/config_profile.dart';
import 'profiles_feature_service.dart';
class ProfileManager with ChangeNotifier {
static const String _profilesKey = 'profiles_library';
static const String activeProfileIdKey = 'profiles_active_profile_id';
static const String _deviceProfileDefaultsKey =
'profiles_device_active_profile_ids';
static const String _transferHistoryKey = 'profiles_transfer_history';
final List<ConfigProfile> _customProfiles = <ConfigProfile>[];
final List<ProfileTransferRecord> _transferHistory =
<ProfileTransferRecord>[];
final Map<String, String> _deviceProfileDefaults = <String, String>{};
bool _isInitialized = false;
bool _profilesEnabled = false;
String _activeProfileId = ConfigProfile.defaultProfileId;
bool get isInitialized => _isInitialized;
bool get profilesEnabled => _profilesEnabled;
String get activeProfileId => _activeProfileId;
List<ConfigProfile> get customProfiles => List.unmodifiable(_customProfiles);
List<ProfileTransferRecord> get transferHistory =>
List.unmodifiable(_transferHistory);
List<ConfigProfile> get visibleProfiles => [
if (_profilesEnabled) ConfigProfile.defaultProfile(),
..._customProfiles,
];
Future<void> initialize() async {
if (_isInitialized) return;
final prefs = await SharedPreferences.getInstance();
_profilesEnabled = await ProfilesFeatureService.isEnabled();
_activeProfileId =
prefs.getString(activeProfileIdKey) ?? ConfigProfile.defaultProfileId;
final deviceDefaultsJson = prefs.getString(_deviceProfileDefaultsKey);
if (deviceDefaultsJson != null && deviceDefaultsJson.isNotEmpty) {
final decoded = jsonDecode(deviceDefaultsJson);
if (decoded is Map<String, dynamic>) {
_deviceProfileDefaults
..clear()
..addAll(
decoded.map((key, value) => MapEntry(key, value?.toString() ?? ''))
..removeWhere((key, value) => value.isEmpty),
);
}
}
final profilesJson = prefs.getString(_profilesKey);
if (profilesJson != null && profilesJson.isNotEmpty) {
final decoded = jsonDecode(profilesJson) as List<dynamic>;
_customProfiles
..clear()
..addAll(
decoded.whereType<Map<String, dynamic>>().map(ConfigProfile.fromJson),
);
}
final historyJson = prefs.getString(_transferHistoryKey);
if (historyJson != null && historyJson.isNotEmpty) {
final decoded = jsonDecode(historyJson) as List<dynamic>;
_transferHistory
..clear()
..addAll(
decoded.whereType<Map<String, dynamic>>().map(
ProfileTransferRecord.fromJson,
),
);
}
_isInitialized = true;
ProfileStorageScope.setScope(
profilesEnabled: _profilesEnabled,
activeProfileId: _activeProfileId,
);
notifyListeners();
}
ConfigProfile? getProfile(String id) {
if (id == ConfigProfile.defaultProfileId) {
return ConfigProfile.defaultProfile();
}
for (final profile in _customProfiles) {
if (profile.id == id) {
return profile;
}
}
return null;
}
Future<void> setProfilesEnabled(bool enabled) async {
_profilesEnabled = enabled;
await ProfilesFeatureService.setEnabled(enabled);
ProfileStorageScope.setScope(
profilesEnabled: _profilesEnabled,
activeProfileId: _activeProfileId,
);
notifyListeners();
}
Future<void> setActiveProfileId(String id) async {
await setActiveProfileIdForDevice(id);
}
String profileIdForDevice(String? deviceKey) {
if (deviceKey == null || deviceKey.isEmpty) {
return _activeProfileId;
}
return _deviceProfileDefaults[deviceKey] ?? ConfigProfile.defaultProfileId;
}
bool hasProfileForDevice(String? deviceKey) {
if (deviceKey == null || deviceKey.isEmpty) {
return false;
}
return _deviceProfileDefaults.containsKey(deviceKey);
}
Future<void> setActiveProfileIdForDevice(
String id, {
String? deviceKey,
}) async {
_activeProfileId = id;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(activeProfileIdKey, id);
if (deviceKey != null && deviceKey.isNotEmpty) {
_deviceProfileDefaults[deviceKey] = id;
await prefs.setString(
_deviceProfileDefaultsKey,
jsonEncode(_deviceProfileDefaults),
);
}
ProfileStorageScope.setScope(
profilesEnabled: _profilesEnabled,
activeProfileId: _activeProfileId,
);
notifyListeners();
}
Future<void> upsertProfile(ConfigProfile profile) async {
final index = _customProfiles.indexWhere((item) => item.id == profile.id);
if (index == -1) {
_customProfiles.add(profile);
} else {
_customProfiles[index] = profile;
}
_customProfiles.sort((a, b) => a.name.compareTo(b.name));
await _persistProfiles();
notifyListeners();
}
Future<void> deleteProfile(String id) async {
_customProfiles.removeWhere((profile) => profile.id == id);
await _persistProfiles();
notifyListeners();
}
Future<void> recordTransfer(ProfileTransferRecord record) async {
_transferHistory.insert(0, record);
if (_transferHistory.length > 100) {
_transferHistory.removeRange(100, _transferHistory.length);
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_transferHistoryKey,
jsonEncode(_transferHistory.map((item) => item.toJson()).toList()),
);
notifyListeners();
}
Future<void> _persistProfiles() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_profilesKey,
jsonEncode(_customProfiles.map((profile) => profile.toJson()).toList()),
);
}
}

View File

@@ -0,0 +1,10 @@
abstract class ProfileTransport {
String get label;
}
class FileProfileTransport implements ProfileTransport {
const FileProfileTransport();
@override
String get label => 'File';
}

View File

@@ -0,0 +1,478 @@
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import '../models/config_profile.dart';
import '../providers/app_provider.dart';
import '../providers/channels_provider.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart';
import '../providers/messages_provider.dart';
import '../providers/sensors_provider.dart';
import 'app_config_snapshot_service.dart';
import 'contact_storage_service.dart';
import 'device_config_applicator.dart';
import 'message_storage_service.dart';
import 'profile_manager.dart';
import 'profile_device_key_resolver.dart';
import 'profiles_feature_service.dart';
import 'map_workspace_snapshot_service.dart';
class ProfileWorkspaceCoordinator {
ProfileWorkspaceCoordinator({
required this.profileManager,
required this.connectionProvider,
required this.contactsProvider,
required this.messagesProvider,
required this.sensorsProvider,
required this.mapProvider,
required this.drawingProvider,
required this.channelsProvider,
required this.appProvider,
AppConfigSnapshotService? appConfigSnapshotService,
MapWorkspaceSnapshotService? mapWorkspaceSnapshotService,
DeviceConfigApplicator? deviceConfigApplicator,
MessageStorageService? messageStorageService,
ContactStorageService? contactStorageService,
}) : _appConfigSnapshotService =
appConfigSnapshotService ?? AppConfigSnapshotService(),
_mapWorkspaceSnapshotService =
mapWorkspaceSnapshotService ?? MapWorkspaceSnapshotService(),
_deviceConfigApplicator =
deviceConfigApplicator ?? DeviceConfigApplicator(),
_messageStorageService =
messageStorageService ?? MessageStorageService(),
_contactStorageService =
contactStorageService ?? ContactStorageService();
final ProfileManager profileManager;
final ConnectionProvider connectionProvider;
final ContactsProvider contactsProvider;
final MessagesProvider messagesProvider;
final SensorsProvider sensorsProvider;
final MapProvider mapProvider;
final DrawingProvider drawingProvider;
final ChannelsProvider channelsProvider;
final AppProvider appProvider;
final AppConfigSnapshotService _appConfigSnapshotService;
final MapWorkspaceSnapshotService _mapWorkspaceSnapshotService;
final DeviceConfigApplicator _deviceConfigApplicator;
final MessageStorageService _messageStorageService;
final ContactStorageService _contactStorageService;
bool _isSyncingDeviceProfile = false;
Future<void> setProfilesEnabled(bool enabled) async {
final wasEnabled = profileManager.profilesEnabled;
if (wasEnabled && !enabled) {
await _saveActiveCustomProfileSnapshot();
} else {
await _persistCurrentState();
}
await profileManager.setProfilesEnabled(enabled);
ProfileStorageScope.setScope(
profilesEnabled: enabled,
activeProfileId: enabled ? profileManager.activeProfileId : 'default',
);
if (enabled) {
await _ensureProfileForCurrentDevice();
if (wasEnabled) {
await openProfile(profileManager.activeProfileId);
} else {
await _switchRuntimeScope(profileManager.activeProfileId);
final profile = await resolveProfile(profileManager.activeProfileId);
await _appConfigSnapshotService.apply(
profile.sections.appSettings,
appProvider,
);
await _mapWorkspaceSnapshotService.apply(
profile.sections.mapWorkspace,
mapProvider: mapProvider,
drawingProvider: drawingProvider,
);
}
} else {
await _switchRuntimeScope('default');
}
}
Future<ConfigProfile> snapshotCurrentProfile({
required String id,
required String name,
String? notes,
}) async {
final deviceSections = _deviceConfigApplicator.capture(
connectionProvider: connectionProvider,
channelsProvider: channelsProvider,
);
final appSettings = await _appConfigSnapshotService.capture(appProvider);
final mapWorkspace = await _mapWorkspaceSnapshotService.capture(
mapProvider: mapProvider,
drawingProvider: drawingProvider,
);
final now = DateTime.now();
return ConfigProfile(
id: id,
name: name,
createdAt: now,
updatedAt: now,
notes: notes,
sections: ConfigProfileSections(
deviceConfig: deviceSections.deviceConfig,
channels: deviceSections.channels,
appSettings: appSettings,
mapWorkspace: mapWorkspace,
),
);
}
Future<ConfigProfile> createProfileFromCurrent({
required String name,
String? notes,
}) async {
final profileId = 'profile_${DateTime.now().millisecondsSinceEpoch}';
final profile = await snapshotCurrentProfile(
id: profileId,
name: name,
notes: notes,
);
await _initializeEmptyStorageNamespace(profileId);
await profileManager.upsertProfile(profile);
return profile;
}
Future<ConfigProfile> duplicateProfile(ConfigProfile source) async {
final duplicate = await snapshotCurrentProfile(
id: 'profile_${DateTime.now().millisecondsSinceEpoch}',
name: '${source.name} Copy',
notes: source.notes,
);
final sourceNamespace = source.id == ConfigProfile.defaultProfileId
? null
: source.id;
await _copyStorageNamespace(sourceNamespace, duplicate.id);
await profileManager.upsertProfile(
duplicate.copyWith(
sections: source.id == profileManager.activeProfileId
? duplicate.sections
: source.sections,
),
);
return duplicate;
}
Future<void> renameProfile(ConfigProfile profile, String name) async {
await profileManager.upsertProfile(
profile.copyWith(name: name, updatedAt: DateTime.now()),
);
}
Future<void> deleteProfile(ConfigProfile profile) async {
if (profile.isDefault) return;
if (profile.id == profileManager.activeProfileId) {
await openProfile(ConfigProfile.defaultProfileId);
}
await _messageStorageService.clearMessages(namespace: profile.id);
await _contactStorageService.clearContacts(namespace: profile.id);
await _contactStorageService.clearContactGroups(namespace: profile.id);
await _contactStorageService.clearPendingAdverts(namespace: profile.id);
await profileManager.deleteProfile(profile.id);
}
Future<void> openProfile(String profileId) async {
final deviceKey = _currentDeviceProfileKey;
await _saveActiveCustomProfileSnapshot();
await connectionProvider.disconnect();
await profileManager.setActiveProfileIdForDevice(
profileId,
deviceKey: deviceKey,
);
await _switchRuntimeScope(profileId);
final profile = await resolveProfile(profileId);
await _appConfigSnapshotService.apply(
profile.sections.appSettings,
appProvider,
);
await _mapWorkspaceSnapshotService.apply(
profile.sections.mapWorkspace,
mapProvider: mapProvider,
drawingProvider: drawingProvider,
);
}
Future<void> applyProfile(String profileId) async {
await openProfile(profileId);
final profile = await resolveProfile(profileId);
await _deviceConfigApplicator.apply(
profile,
connectionProvider: connectionProvider,
channelsProvider: channelsProvider,
);
}
Future<ConfigProfile> resolveProfile(String profileId) async {
if (profileId == ConfigProfile.defaultProfileId) {
return snapshotCurrentProfile(id: profileId, name: 'Default');
}
return profileManager.getProfile(profileId) ??
await snapshotCurrentProfile(id: profileId, name: 'Unknown');
}
Future<void> exportProfile(ConfigProfile profile) async {
final resolved = profile.id == ConfigProfile.defaultProfileId
? await snapshotCurrentProfile(id: profile.id, name: profile.name)
: profile;
final tempDir = await getTemporaryDirectory();
final file = File(
'${tempDir.path}/${resolved.name.replaceAll(' ', '_').toLowerCase()}_${resolved.id}.meshcore_profile.json',
);
await file.writeAsString(
const JsonEncoder.withIndent(' ').convert(resolved.toJson()),
);
await SharePlus.instance.share(ShareParams(files: [XFile(file.path)]));
await profileManager.recordTransfer(
ProfileTransferRecord(
profileId: resolved.id,
direction: 'export',
timestamp: DateTime.now(),
detail: 'Shared as file',
),
);
}
Future<ConfigProfile?> importProfileFromFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['json'],
withData: true,
);
if (result == null || result.files.isEmpty) {
return null;
}
final file = result.files.first;
if (file.bytes == null) return null;
final decoded = jsonDecode(utf8.decode(file.bytes!));
if (decoded is! Map<String, dynamic>) {
return null;
}
final imported = ConfigProfile.fromJson(decoded).copyWith(
id: 'profile_${DateTime.now().millisecondsSinceEpoch}',
updatedAt: DateTime.now(),
);
await profileManager.upsertProfile(imported);
await profileManager.recordTransfer(
ProfileTransferRecord(
profileId: imported.id,
direction: 'import',
timestamp: DateTime.now(),
detail: file.name,
),
);
return imported;
}
Future<void> syncActiveProfileForCurrentDevice() async {
if (!profileManager.profilesEnabled || _isSyncingDeviceProfile) {
return;
}
final deviceKey = _currentDeviceProfileKey;
if (deviceKey == null) {
return;
}
_isSyncingDeviceProfile = true;
try {
final profile = await _ensureProfileForCurrentDevice();
final targetProfileId = profile.id;
if (targetProfileId == profileManager.activeProfileId) {
return;
}
await _persistCurrentState();
await profileManager.setActiveProfileIdForDevice(
targetProfileId,
deviceKey: deviceKey,
);
await _switchRuntimeScope(targetProfileId);
await _appConfigSnapshotService.apply(
profile.sections.appSettings,
appProvider,
);
await _mapWorkspaceSnapshotService.apply(
profile.sections.mapWorkspace,
mapProvider: mapProvider,
drawingProvider: drawingProvider,
);
} finally {
_isSyncingDeviceProfile = false;
}
}
Future<ConfigProfile> _ensureProfileForCurrentDevice() async {
final deviceKey = _currentDeviceProfileKey;
final targetProfileId = profileManager.profileIdForDevice(deviceKey);
final existingProfile = profileManager.getProfile(targetProfileId);
if (profileManager.hasProfileForDevice(deviceKey) &&
existingProfile != null) {
return existingProfile;
}
if (profileManager.hasProfileForDevice(deviceKey) &&
targetProfileId == ConfigProfile.defaultProfileId) {
return ConfigProfile.defaultProfile();
}
if (deviceKey == null) {
return await resolveProfile(profileManager.activeProfileId);
}
final profile = await createProfileFromCurrent(
name: _buildDeviceProfileName(),
);
await profileManager.setActiveProfileIdForDevice(
profile.id,
deviceKey: deviceKey,
);
return profile;
}
String _buildDeviceProfileName() {
final deviceInfo = connectionProvider.deviceInfo;
final name = deviceInfo.selfName?.trim();
if (name != null && name.isNotEmpty) {
return 'Device ${_sanitizeProfileLabel(name)}';
}
final displayName = deviceInfo.displayName?.trim();
if (displayName != null && displayName.isNotEmpty) {
return 'Device ${_sanitizeProfileLabel(displayName)}';
}
final deviceId = deviceInfo.deviceId?.trim();
if (deviceId != null && deviceId.isNotEmpty) {
return 'Device ${_sanitizeProfileLabel(deviceId)}';
}
return 'Device Profile';
}
String _sanitizeProfileLabel(String value) {
return value.replaceAll(RegExp(r'\s+'), ' ').trim();
}
String? get _currentDeviceProfileKey => ProfileDeviceKeyResolver.resolve(
deviceInfo: connectionProvider.deviceInfo,
connectionMode: connectionProvider.connectionMode,
);
Future<void> _switchRuntimeScope(String profileId) async {
final runtimeProfilesEnabled = profileManager.profilesEnabled;
ProfileStorageScope.setScope(
profilesEnabled: runtimeProfilesEnabled,
activeProfileId: profileId,
);
await messagesProvider.reloadFromStorage(
namespace: ProfileStorageScope.effectiveNamespace,
);
await contactsProvider.reloadFromStorage(
namespace: ProfileStorageScope.effectiveNamespace,
devicePublicKey: connectionProvider.deviceInfo.publicKey,
);
await sensorsProvider.reloadProfileScopedState();
await drawingProvider.reloadProfileScopedState();
await mapProvider.reloadProfileScopedState();
await appProvider.reloadProfileScopedSettings();
}
Future<void> _persistCurrentState() async {
await messagesProvider.persistNow();
await contactsProvider.persistNow();
}
Future<void> _saveActiveCustomProfileSnapshot() async {
if (profileManager.activeProfileId == ConfigProfile.defaultProfileId) {
await _persistCurrentState();
return;
}
final current = profileManager.getProfile(profileManager.activeProfileId);
if (current == null) {
await _persistCurrentState();
return;
}
final snapshot = await snapshotCurrentProfile(
id: current.id,
name: current.name,
notes: current.notes,
);
await profileManager.upsertProfile(snapshot);
await _persistCurrentState();
}
Future<void> _copyStorageNamespace(
String? sourceNamespace,
String targetNamespace,
) async {
final messages = await _messageStorageService.loadMessages(
namespace: sourceNamespace,
);
final contactLocations = await _messageStorageService
.loadMessageContactLocations(namespace: sourceNamespace);
final receptionDetails = await _messageStorageService
.loadMessageReceptionDetails(namespace: sourceNamespace);
final transferDetails = await _messageStorageService
.loadMessageTransferDetails(namespace: sourceNamespace);
final routeMetadata = await _messageStorageService.loadMessageRouteMetadata(
namespace: sourceNamespace,
);
final removedIds = await _messageStorageService.loadRemovedSarMarkerIds(
namespace: sourceNamespace,
);
await _messageStorageService.saveMessages(
messages,
messageContactLocations: contactLocations,
messageReceptionDetails: receptionDetails,
messageTransferDetails: transferDetails,
messageRouteMetadata: routeMetadata,
namespace: targetNamespace,
);
await _messageStorageService.saveRemovedSarMarkerIds(
removedIds,
namespace: targetNamespace,
);
final contacts = await _contactStorageService.loadContacts(
namespace: sourceNamespace,
);
final groups = await _contactStorageService.loadContactGroups(
namespace: sourceNamespace,
);
final pending = await _contactStorageService.loadPendingAdverts(
namespace: sourceNamespace,
);
await _contactStorageService.saveContacts(
contacts,
namespace: targetNamespace,
);
await _contactStorageService.saveContactGroups(
groups,
namespace: targetNamespace,
);
await _contactStorageService.savePendingAdverts(
pending,
namespace: targetNamespace,
);
}
Future<void> _initializeEmptyStorageNamespace(String namespace) async {
await _messageStorageService.clearMessages(namespace: namespace);
await _contactStorageService.clearContacts(namespace: namespace);
await _contactStorageService.clearContactGroups(namespace: namespace);
await _contactStorageService.clearPendingAdverts(namespace: namespace);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:shared_preferences/shared_preferences.dart';
class ProfilesFeatureService {
static const String enabledKey = 'profiles_enabled';
static Future<bool> isEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(enabledKey) ?? true;
}
static Future<void> setEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(enabledKey, enabled);
}
}
class ProfileStorageScope {
static bool _profilesEnabled = true;
static String _activeProfileId = 'default';
static Future<void> bootstrap({
required bool profilesEnabled,
required String activeProfileId,
}) async {
_profilesEnabled = profilesEnabled;
_activeProfileId = activeProfileId;
}
static void setScope({
required bool profilesEnabled,
required String activeProfileId,
}) {
_profilesEnabled = profilesEnabled;
_activeProfileId = activeProfileId;
}
static bool get profilesEnabled => _profilesEnabled;
static String get activeProfileId => _activeProfileId;
static String? get effectiveNamespace {
if (!_profilesEnabled || _activeProfileId == 'default') {
return null;
}
return _activeProfileId;
}
static String scopedKey(String baseKey, {String? namespace}) {
final scope = namespace ?? effectiveNamespace;
if (scope == null || scope.isEmpty) {
return baseKey;
}
return 'profile.$scope.$baseKey';
}
}

View File

@@ -0,0 +1,48 @@
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import '../models/contact.dart';
class RelayCandidateSorter {
const RelayCandidateSorter();
List<Contact> sortByDistanceFromSelf(
List<Contact> contacts, {
required LatLng? selfPoint,
}) {
final sorted = List<Contact>.from(contacts);
sorted.sort((a, b) {
if (selfPoint != null) {
final distanceCompare = _distanceFrom(
selfPoint,
a,
).compareTo(_distanceFrom(selfPoint, b));
if (distanceCompare != 0) {
return distanceCompare;
}
}
final nameCompare = a.displayName.compareTo(b.displayName);
if (nameCompare != 0) {
return nameCompare;
}
return a.publicKeyHex.compareTo(b.publicKeyHex);
});
return sorted;
}
double _distanceFrom(LatLng selfPoint, Contact contact) {
final location = contact.displayLocation;
if (location == null) {
return double.infinity;
}
return Geolocator.distanceBetween(
selfPoint.latitude,
selfPoint.longitude,
location.latitude,
location.longitude,
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
class RouteHashPreferences {
static const String _hashSizeKey = 'route_hash_size';
@@ -6,21 +7,28 @@ class RouteHashPreferences {
static Future<int> getHashSize() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_hashSizeKey) ?? defaultHashSize;
final value =
prefs.getInt(ProfileStorageScope.scopedKey(_hashSizeKey)) ??
defaultHashSize;
return _normalize(value);
}
static Future<void> setHashSize(int value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_hashSizeKey, _normalize(value));
await prefs.setInt(
ProfileStorageScope.scopedKey(_hashSizeKey),
_normalize(value),
);
}
static int normalizeSync(int value) => _normalize(value);
/// Valid hash sizes per the MeshCore protocol.
/// The path encoding uses 2 bits: 00=1B, 01=2B, 10=3B, 11=4B.
static const List<int> supportedSizes = [1, 2, 3];
static int _normalize(int value) {
if (value < 1 || value > 3) {
return defaultHashSize;
}
return value;
if (supportedSizes.contains(value)) return value;
return defaultHashSize;
}
}

View File

@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:usb_serial/usb_serial.dart';
import 'package:meshcore_client/meshcore_client.dart';
/// Android USB OTG transport for MeshCore serial connection.
///
/// Wraps the `usb_serial` package to provide raw byte I/O to
/// [MeshCoreSerialService].
class UsbSerialTransport {
static const int _baudRate = 115200;
UsbPort? _port;
StreamSubscription? _inputSubscription;
final MeshCoreSerialService _service;
UsbSerialTransport(this._service);
MeshCoreSerialService get service => _service;
bool get isConnected => _port != null;
/// List available USB serial devices.
static Future<List<UsbDevice>> listDevices() async {
return UsbSerial.listDevices();
}
/// Connect to a USB serial device.
Future<bool> connect(UsbDevice device) async {
try {
_port = await device.create();
if (_port == null) {
debugPrint('❌ [USB] Failed to create port for ${device.productName}');
return false;
}
final opened = await _port!.open();
if (!opened) {
debugPrint('❌ [USB] Failed to open port');
_port = null;
return false;
}
await _port!.setDTR(true);
await _port!.setRTS(true);
await _port!.setPortParameters(
_baudRate,
UsbPort.DATABITS_8,
UsbPort.STOPBITS_1,
UsbPort.PARITY_NONE,
);
// Wire write callback
_service.writeRaw = _write;
// Listen for incoming data
_inputSubscription = _port!.inputStream?.listen(
(data) => _service.feedRawBytes(data),
onError: (error) {
debugPrint('❌ [USB] Read error: $error');
disconnect();
},
onDone: () {
debugPrint('⚠️ [USB] Port closed');
disconnect();
},
);
debugPrint(
'✅ [USB] Connected to ${device.productName} at $_baudRate baud',
);
// Initialize MeshCore session
return _service.markConnected();
} catch (e) {
debugPrint('❌ [USB] Connection failed: $e');
await disconnect();
return false;
}
}
/// Disconnect from the USB device.
Future<void> disconnect() async {
_service.markDisconnected();
_service.writeRaw = null;
await _inputSubscription?.cancel();
_inputSubscription = null;
await _port?.close();
_port = null;
}
Future<void> _write(Uint8List data) async {
if (_port == null) throw Exception('USB not connected');
await _port!.write(data);
}
void dispose() {
disconnect();
}
}

View File

@@ -0,0 +1,20 @@
import 'dart:async';
import 'package:meshcore_client/meshcore_client.dart';
// Web Serial API is only available on web platform.
// This file provides the interface; the actual JS interop is conditional.
/// Web Serial transport stub for non-web platforms.
/// On web, use [WebSerialTransportImpl] from web_serial_transport_web.dart.
class WebSerialTransport {
final MeshCoreSerialService service;
WebSerialTransport(this.service);
bool get isConnected => false;
bool get isSupported => false;
Future<bool> requestAndConnect() async => false;
Future<void> disconnect() async {}
void dispose() {}
}

View File

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

View File

@@ -1,21 +1,32 @@
import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
import '../utils/voice_message_parser.dart';
/// Stores user-selected voice bitrate and maps it to supported codec modes.
class VoiceBitratePreferences {
static const String _bitrateKey = 'voice_bitrate';
static const int defaultBitrate = 1300;
static const List<int> supportedBitrates = [700, 1200, 1300, 1400, 1600, 2400, 3200];
static const List<int> supportedBitrates = [
700,
1200,
1300,
1400,
1600,
2400,
3200,
];
static Future<int> getBitrate() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_bitrateKey) ?? defaultBitrate;
final value =
prefs.getInt(ProfileStorageScope.scopedKey(_bitrateKey)) ??
defaultBitrate;
return supportedBitrates.contains(value) ? value : defaultBitrate;
}
static Future<void> setBitrate(int bitrate) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_bitrateKey, bitrate);
await prefs.setInt(ProfileStorageScope.scopedKey(_bitrateKey), bitrate);
}
static VoicePacketMode toVoiceMode(int bitrate) {

View File

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

View File

@@ -1,123 +1,141 @@
import 'package:flutter/material.dart';
enum AppThemeMode {
light,
dark,
sarRed,
sarGreen,
sarNavyBlue,
system,
}
enum AppThemeMode { light, dark, sarRed, sarGreen, sarNavyBlue, system }
class AppTheme {
static ThemeData _withSharedControls(ThemeData theme) {
final colorScheme = theme.colorScheme;
return theme.copyWith(
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return colorScheme.onSurface.withValues(alpha: 0.38);
}
if (states.contains(WidgetState.selected)) {
return colorScheme.onPrimary;
}
return colorScheme.outline;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.disabled)) {
return colorScheme.onSurface.withValues(alpha: 0.12);
}
if (states.contains(WidgetState.selected)) {
return colorScheme.primary;
}
return colorScheme.surfaceContainerHighest;
}),
trackOutlineColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return colorScheme.primary;
}
return colorScheme.outlineVariant;
}),
),
);
}
// Light theme (Blue)
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
return _withSharedControls(
ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
),
filled: true,
),
);
}
// Dark theme (Blue)
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
return _withSharedControls(
ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
filled: true,
),
filled: true,
),
);
}
// SAR Red theme (Emergency/Alert tones)
static ThemeData get sarRedTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFFFF5252), // Bright red
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF8B0000), // Dark red
onPrimaryContainer: Color(0xFFFFCDD2),
secondary: Color(0xFFFF8A80),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFFB71C1C),
onSecondaryContainer: Color(0xFFFFCDD2),
tertiary: Color(0xFFFF6E40),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF1A0000), // Very dark red-tinted
onSurface: Color(0xFFFFEBEE),
surfaceContainerHighest: Color(0xFF2D0000),
onSurfaceVariant: Color(0xFFFFCDD2),
outline: Color(0xFFFF5252),
),
scaffoldBackgroundColor: const Color(0xFF1A0000),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF2D0000),
foregroundColor: Color(0xFFFFEBEE),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF2D0000),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: Color(0xFFFF5252),
width: 1,
return _withSharedControls(
ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFFFF5252), // Bright red
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF8B0000), // Dark red
onPrimaryContainer: Color(0xFFFFCDD2),
secondary: Color(0xFFFF8A80),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFFB71C1C),
onSecondaryContainer: Color(0xFFFFCDD2),
tertiary: Color(0xFFFF6E40),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF1A0000), // Very dark red-tinted
onSurface: Color(0xFFFFEBEE),
surfaceContainerHighest: Color(0xFF2D0000),
onSurfaceVariant: Color(0xFFFFCDD2),
outline: Color(0xFFFF5252),
),
scaffoldBackgroundColor: const Color(0xFF1A0000),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF2D0000),
foregroundColor: Color(0xFFFFEBEE),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF2D0000),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFFF5252), width: 1),
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFFF5252)),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFFF5252)),
),
filled: true,
fillColor: const Color(0xFF2D0000),
),
filled: true,
fillColor: const Color(0xFF2D0000),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF5252),
foregroundColor: const Color(0xFF000000),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFF5252),
foregroundColor: const Color(0xFF000000),
),
),
),
);
@@ -125,58 +143,57 @@ class AppTheme {
// SAR Green theme (All Clear/Safe tones)
static ThemeData get sarGreenTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF69F0AE), // Bright green
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF00695C), // Dark teal-green
onPrimaryContainer: Color(0xFFB9F6CA),
secondary: Color(0xFF64FFDA),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFF004D40),
onSecondaryContainer: Color(0xFFB9F6CA),
tertiary: Color(0xFF1DE9B6),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF001A12), // Very dark green-tinted
onSurface: Color(0xFFE8F5E9),
surfaceContainerHighest: Color(0xFF002D1F),
onSurfaceVariant: Color(0xFFB9F6CA),
outline: Color(0xFF69F0AE),
),
scaffoldBackgroundColor: const Color(0xFF001A12),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF002D1F),
foregroundColor: Color(0xFFE8F5E9),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF002D1F),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: Color(0xFF69F0AE),
width: 1,
return _withSharedControls(
ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF69F0AE), // Bright green
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF00695C), // Dark teal-green
onPrimaryContainer: Color(0xFFB9F6CA),
secondary: Color(0xFF64FFDA),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFF004D40),
onSecondaryContainer: Color(0xFFB9F6CA),
tertiary: Color(0xFF1DE9B6),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF001A12), // Very dark green-tinted
onSurface: Color(0xFFE8F5E9),
surfaceContainerHighest: Color(0xFF002D1F),
onSurfaceVariant: Color(0xFFB9F6CA),
outline: Color(0xFF69F0AE),
),
scaffoldBackgroundColor: const Color(0xFF001A12),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF002D1F),
foregroundColor: Color(0xFFE8F5E9),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF002D1F),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFF69F0AE), width: 1),
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF69F0AE)),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF69F0AE)),
),
filled: true,
fillColor: const Color(0xFF002D1F),
),
filled: true,
fillColor: const Color(0xFF002D1F),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF69F0AE),
foregroundColor: const Color(0xFF000000),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF69F0AE),
foregroundColor: const Color(0xFF000000),
),
),
),
);
@@ -184,58 +201,57 @@ class AppTheme {
// SAR Navy Blue theme (Professional/Operations tones)
static ThemeData get sarNavyBlueTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF5C9FFF), // Bright navy blue
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF003366), // Dark navy
onPrimaryContainer: Color(0xFFBBDEFF),
secondary: Color(0xFF80B3FF),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFF002244),
onSecondaryContainer: Color(0xFFBBDEFF),
tertiary: Color(0xFF4DB8FF),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF00111C), // Very dark blue-tinted
onSurface: Color(0xFFE3F2FD),
surfaceContainerHighest: Color(0xFF001A2D),
onSurfaceVariant: Color(0xFFBBDEFF),
outline: Color(0xFF5C9FFF),
),
scaffoldBackgroundColor: const Color(0xFF00111C),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF001A2D),
foregroundColor: Color(0xFFE3F2FD),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF001A2D),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: Color(0xFF5C9FFF),
width: 1,
return _withSharedControls(
ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
brightness: Brightness.dark,
primary: Color(0xFF5C9FFF), // Bright navy blue
onPrimary: Color(0xFF000000),
primaryContainer: Color(0xFF003366), // Dark navy
onPrimaryContainer: Color(0xFFBBDEFF),
secondary: Color(0xFF80B3FF),
onSecondary: Color(0xFF000000),
secondaryContainer: Color(0xFF002244),
onSecondaryContainer: Color(0xFFBBDEFF),
tertiary: Color(0xFF4DB8FF),
onTertiary: Color(0xFF000000),
error: Color(0xFFCF6679),
onError: Color(0xFF000000),
surface: Color(0xFF00111C), // Very dark blue-tinted
onSurface: Color(0xFFE3F2FD),
surfaceContainerHighest: Color(0xFF001A2D),
onSurfaceVariant: Color(0xFFBBDEFF),
outline: Color(0xFF5C9FFF),
),
scaffoldBackgroundColor: const Color(0xFF00111C),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
backgroundColor: Color(0xFF001A2D),
foregroundColor: Color(0xFFE3F2FD),
),
cardTheme: CardThemeData(
elevation: 2,
color: const Color(0xFF001A2D),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFF5C9FFF), width: 1),
),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF5C9FFF)),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF5C9FFF)),
),
filled: true,
fillColor: const Color(0xFF001A2D),
),
filled: true,
fillColor: const Color(0xFF001A2D),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF5C9FFF),
foregroundColor: const Color(0xFF000000),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF5C9FFF),
foregroundColor: const Color(0xFF000000),
),
),
),
);

View File

@@ -24,4 +24,14 @@ class BatteryDisplayHelper {
if (rssi > -70) return Colors.orange;
return Colors.red;
}
/// Number of active signal bars (0-5) for a BLE RSSI value.
static int getSignalBars(int rssi) {
if (rssi > -50) return 5;
if (rssi > -60) return 4;
if (rssi > -70) return 3;
if (rssi > -80) return 2;
if (rssi > -90) return 1;
return 0;
}
}

View File

@@ -0,0 +1,18 @@
const int customMapKey6HexLength = 12;
String? normalizeCustomMapId(String? mapId) {
if (mapId == null) {
return null;
}
final normalized = mapId.trim().toLowerCase();
if (normalized.isEmpty) {
return null;
}
if (normalized.length <= customMapKey6HexLength) {
return normalized;
}
return normalized.substring(0, customMapKey6HexLength);
}

View File

@@ -1,19 +1,17 @@
import 'dart:convert';
import '../models/map_coordinate_space.dart';
import '../models/map_drawing.dart';
import 'custom_map_id.dart';
/// Parser for drawing messages transmitted over mesh network
class DrawingMessageParser {
/// Drawing message prefix
static const String prefix = 'D:';
static const String legacyPrefix = 'D:';
static const String customMapPrefix = 'D2:';
/// Check if message is a drawing message
static bool isDrawingMessage(String text) {
return text.startsWith(prefix);
return text.startsWith(legacyPrefix) || text.startsWith(customMapPrefix);
}
/// Parse drawing message text into MapDrawing object
/// senderName and messageId should be extracted from packet metadata
/// Returns null if parsing fails
static MapDrawing? parseDrawingMessage(
String text, {
String? senderName,
@@ -24,126 +22,96 @@ class DrawingMessageParser {
}
try {
// Remove prefix
final jsonStr = text.substring(prefix.length);
if (text.startsWith(customMapPrefix)) {
final json =
jsonDecode(text.substring(customMapPrefix.length))
as Map<String, dynamic>;
final mapId = normalizeCustomMapId(json['m'] as String?);
if (mapId == null || mapId.isEmpty) {
return null;
}
return MapDrawing.fromNetworkJson(
json,
senderName: senderName,
messageId: messageId,
coordinateSpace: MapCoordinateSpace.customMap,
mapId: mapId,
);
}
// Parse JSON
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
// Use ultra-compact network format parser
// Sender name and message ID come from packet metadata, not JSON
final json =
jsonDecode(text.substring(legacyPrefix.length))
as Map<String, dynamic>;
return MapDrawing.fromNetworkJson(
json,
senderName: senderName,
messageId: messageId,
coordinateSpace: MapCoordinateSpace.geo,
);
} catch (e) {
} catch (_) {
return null;
}
}
/// Create drawing message text from MapDrawing object
/// Sender will be determined from packet metadata on receiving end
static String createDrawingMessage(MapDrawing drawing) {
final json = drawing.toNetworkJson();
final jsonStr = jsonEncode(json).toString();
return '$prefix$jsonStr';
final prefix = drawing.coordinateSpace == MapCoordinateSpace.customMap
? customMapPrefix
: legacyPrefix;
return '$prefix${jsonEncode(json)}';
}
/// Get drawing type display name from drawing message text
/// Returns "Line" or "Rectangle", or null if parsing fails
static String? getDrawingTypeDisplay(String text) {
if (!isDrawingMessage(text)) return null;
try {
final jsonStr = text.substring(prefix.length);
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
final typeNum = json['t'] as int?;
if (typeNum == null) return null;
switch (typeNum) {
case 0:
return 'Line';
case 1:
return 'Rectangle';
default:
return null;
}
} catch (e) {
return null;
}
final metadata = getDrawingMetadata(text);
return metadata?['type'] as String?;
}
/// Get color name from drawing message text
/// Returns color name like "Red", "Blue", etc., or null if parsing fails
static String? getColorName(String text) {
if (!isDrawingMessage(text)) return null;
try {
final jsonStr = text.substring(prefix.length);
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
final colorIndex = json['c'] as int?;
if (colorIndex == null) return null;
// Color mapping from DrawingColor enum
const colorNames = [
'Red', // 0
'Blue', // 1
'Green', // 2
'Yellow', // 3
'Orange', // 4
'Purple', // 5
'Pink', // 6
'Cyan', // 7
];
if (colorIndex >= 0 && colorIndex < colorNames.length) {
return colorNames[colorIndex];
}
return null;
} catch (e) {
return null;
}
final metadata = getDrawingMetadata(text);
return metadata?['color'] as String?;
}
/// Get drawing metadata for display in message bubbles
/// Returns map with type, color, and pointCount, or null if parsing fails
static Map<String, dynamic>? getDrawingMetadata(String text) {
if (!isDrawingMessage(text)) return null;
try {
final jsonStr = text.substring(prefix.length);
final json = jsonDecode(jsonStr) as Map<String, dynamic>;
final json =
jsonDecode(
text.startsWith(customMapPrefix)
? text.substring(customMapPrefix.length)
: text.substring(legacyPrefix.length),
)
as Map<String, dynamic>;
final typeNum = json['t'] as int?;
final colorIndex = json['c'] as int?;
if (typeNum == null || colorIndex == null) return null;
// Get type display name
String type;
int? pointCount;
switch (typeNum) {
case 0: // Line
case 0:
type = 'Line';
final points = json['p'] as List?;
pointCount = points != null ? points.length ~/ 2 : null;
break;
case 1: // Rectangle
case 1:
type = 'Rectangle';
pointCount = 4; // Rectangles always have 4 corners
pointCount = 4;
break;
default:
return null;
}
// Get color name
const colorNames = [
'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Purple', 'Pink', 'Cyan',
'Red',
'Blue',
'Green',
'Yellow',
'Orange',
'Purple',
'Pink',
'Cyan',
];
final color = colorIndex >= 0 && colorIndex < colorNames.length
? colorNames[colorIndex]
@@ -153,8 +121,12 @@ class DrawingMessageParser {
'type': type,
'color': color,
'pointCount': pointCount,
'coordinateSpace': text.startsWith(customMapPrefix)
? MapCoordinateSpace.customMap.name
: MapCoordinateSpace.geo.name,
'mapId': normalizeCustomMapId(json['m'] as String?),
};
} catch (e) {
} catch (_) {
return null;
}
}

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