Compare commits

...

178 Commits

Author SHA1 Message Date
Janez T
6978f24440 fix: Merge self replay and refresh self telemetry 2026-03-24 19:07:44 +01:00
Janez T
ba0f1fc141 feat: Add fast GPS send logs 2026-03-23 21:00:41 +01:00
Janez T
28a9235168 fix: Tame rapid GPS updates #41 2026-03-23 20:51:48 +01:00
Janez T
3e3c9a34d5 fix: Keep received replays separate 2026-03-23 20:07:06 +01:00
Janez T
e85dccea35 feat: Add location-based DM retries 2026-03-23 15:16:59 +01:00
Janez T
019224d955 fix: Hide public channel and show signal pills #123 2026-03-23 15:04:45 +01:00
Janez T
4c048a5a50 fix: Show recipient activity previews 2026-03-23 10:44:20 +01:00
Janez T
2b42e7dd15 feat: Add compass and region support 2026-03-22 19:04:43 +01:00
Janez T
7ea6334f02 fix: Keep composer and sensor sheets closable #123 2026-03-22 18:27:57 +01:00
Janez Troha
6f0a66dc68 Merge pull request #35 from dz0ny/feat/channel-datagrams
feat: Add repeater discovery onboarding flow
2026-03-22 18:06:59 +01:00
Janez T
a26ae96acd feat: Add repeater discovery onboarding flow 2026-03-22 18:06:24 +01:00
Janez Troha
a6dc137e51 Merge pull request #34 from dz0ny/feat/channel-datagrams
feat: Add channel datagrams #1928
2026-03-22 17:08:34 +01:00
Janez Troha
c2501442e3 Merge pull request #33 from dz0ny/feat/update-changelog-with-highlights
Update changelog with weekly highlights and PR links
2026-03-22 17:08:13 +01:00
Janez T
71eac21df9 feat: Add channel datagrams #1928
ref:
2026-03-22 16:46:58 +01:00
Janez T
c299349b6a chore: Update changelog #0 2026-03-22 16:02:17 +01:00
Janez T
ab05f13de9 fix: Remove contact trails from map #123 2026-03-22 15:48:55 +01:00
Janez T
0e61dc5bf1 fix: Revert Darwin BLE override 2026-03-22 13:57:14 +01:00
Janez T
d45924d7b4 fix: Vendor Darwin BLE override 2026-03-22 13:56:23 +01:00
Janez T
a24e699e22 fix: Refresh MeshCore lock #31
ref: dz0ny/meshcore-sar#31
2026-03-22 09:49:16 +01:00
Janez T
7486837ad0 fix: Compact recipient selector #0 2026-03-22 09:37:53 +01:00
Janez T
059125c714 fix: Restore telemetry refresh behavior
ref:
2026-03-22 09:19:33 +01:00
Janez T
9279ff2158 chore: Bump iOS build number #123 2026-03-21 20:45:09 +01:00
Janez T
dc297b0b9f fix: Tighten device settings layout 2026-03-21 20:44:29 +01:00
Janez T
2d24481aba feat: Add MET history isolation 2026-03-21 19:22:43 +01:00
Janez T
20a1df94eb fix: Support MeshCore message links #123 2026-03-21 18:36:56 +01:00
Janez T
5cc81bef65 feat: Show self sensor default 2026-03-20 19:08:24 +01:00
Janez T
6145462555 chore: Bump iOS build #123 2026-03-19 21:39:51 +01:00
Janez T
721fb7e7a2 feat: Add serial compass support #0 2026-03-19 21:38:54 +01:00
Janez T
5a58e45e13 fix: Add contacts test providers 2026-03-19 14:28:10 +01:00
Janez T
de4258c0a4 fix: Resolve DeviceInfo import clash 2026-03-19 14:18:48 +01:00
Janez T
d8cb35c595 fix: Simplify device settings styling #123 2026-03-19 14:18:29 +01:00
Janez T
8da69705ee fix: Align contact sorting labels #0 2026-03-19 14:06:05 +01:00
Janez T
e474168ba8 feat: Show self telemetry in device info sheet
Tapping signal/battery in header now also shows device's own telemetry
(temperature, humidity, pressure, GPS) when available. Triggers a self
telemetry request on sheet open. Data comes from the device contact's
telemetry field.
2026-03-19 13:52:15 +01:00
Janez T
03dba8b3ce feat: Device info sheet on signal/battery tap
Tapping the signal bars or battery indicator in the header now shows
a bottom sheet with device details: BLE signal (dBm), battery %,
voltage, storage usage, firmware version, frequency, and TX power.

Fixes #22 (partial — device telemetry display)
2026-03-19 11:07:08 +01:00
Janez T
bc9e37bd22 fix: Count RX data and speed up Flutter builds #123 2026-03-19 10:33:20 +01:00
Janez T
a32d5d2c85 fix: Show irradiance values #123 2026-03-19 09:24:53 +01:00
Janez T
e683ab933f fix: Show original poster name in room messages, not room server name
Room servers forward messages with the original author's 4-byte public
key prefix (roomPostAuthorPrefix). Was ignoring this — all room messages
showed the room server's name as sender.

Now resolves the actual poster's name from roomPostAuthorPrefix by
matching against the contacts list. Falls back to room server name if
the author isn't a known contact.

Also fixes findContactByPrefix to accept 4-byte prefixes (was rejecting
anything shorter than 6 bytes).

Fixes #22
2026-03-19 09:20:22 +01:00
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
Janez T
e7a481f97b Warn channels for delayed log rx 2026-03-13 20:32:07 +01:00
Janez T
9f31f50e61 Warn channels about delayed message 2026-03-13 20:14:07 +01:00
Janez T
d4237b7569 Add channel echo warning tracking 2026-03-13 20:07:39 +01:00
Janez T
b35c11c804 fix: bump meshcore client
ref:
2026-03-13 20:02:13 +01:00
Janez T
be2cf53374 Investigate Command 0x1f timeout 2026-03-13 19:58:41 +01:00
Janez T
13a729492f fix: update meshcore client ref
ref:
2026-03-13 19:56:50 +01:00
Janez T
9d8594fe05 Fix channel message dedup logic 2026-03-13 19:39:26 +01:00
Janez T
249268dd00 Prevent duplicate channel messages 2026-03-13 19:39:03 +01:00
Janez T
63aa9619e6 Clear message input on send 2026-03-13 19:38:05 +01:00
Janez T
a7ad4bb8ec Clear message input after send 2026-03-13 19:36:20 +01:00
Janez T
636e114428 Fix channel syncing and logging 2026-03-13 19:27:14 +01:00
Janez T
d5684c51c7 Fix channel sync and logging 2026-03-13 19:24:42 +01:00
Janez T
ced39b6992 Fix channel sync errors 2026-03-13 19:15:41 +01:00
Janez T
083b715039 Add clearChannelMessages to Messages 2026-03-13 18:55:05 +01:00
Janez T
36584be0ab Fix channel deletion state 2026-03-13 18:49:54 +01:00
Janez T
dc19296804 Fix channel overwrite bug 2026-03-13 18:48:17 +01:00
Janez T
40072175e3 Prevent duplicate hash channels 2026-03-13 18:46:33 +01:00
Janez T
11f3bf9f97 Fix duplicate self messages 2026-03-13 18:44:17 +01:00
Janez T
24e715d01e Bump iOS project version 2026-03-13 14:18:27 +01:00
Janez T
6e17de3866 Add profile storage and sync 2026-03-13 10:34:52 +01:00
Janez T
90877a1891 Add bidirectional refresh physics 2026-03-13 10:31:37 +01:00
Janez T
77e43843bf Update contacts tab widgets 2026-03-13 10:25:23 +01:00
Janez T
1e70f52069 Allow overriding contact name 2026-03-13 10:16:04 +01:00
Janez T
5e404944e9 Investigate REQ PATH usage 2026-03-12 20:56:37 +01:00
Janez T
53daf2c9a7 Add packet filter stats and hex 2026-03-12 20:54:04 +01:00
Janez T
c36d63a0ad Add Flutter live traffic monitor 2026-03-12 20:19:24 +01:00
Janez T
69ec2dfdb5 Add live mesh traffic view 2026-03-12 20:12:25 +01:00
Janez T
d2e6b692f3 Fix voice quality regression 2026-03-12 19:38:41 +01:00
Janez T
baa1aa6edd Update contacts filter UI 2026-03-12 14:46:36 +01:00
Janez T
38cb3dc031 Fix retry flow and contact filters 2026-03-12 14:41:12 +01:00
Janez T
abb73a4c65 Fix retry flow and contact filters 2026-03-12 14:39:44 +01:00
Janez T
2d1e6c0ca4 Fix tracing path and display issues 2026-03-12 14:08:00 +01:00
Janez T
b38a8c55d6 Switch lpcnet plugin to git 2026-03-11 21:20:53 +01:00
Janez T
ca872f2049 Add LPCNet voice mode support 2026-03-11 21:16:08 +01:00
Janez T
1b6ff59af3 Add LPCNet voice mode option 2026-03-11 20:47:17 +01:00
Janez T
e31bfac2ac Retain rx path for adverts 2026-03-11 20:21:17 +01:00
Janez T
99bb446ee9 Update iOS Runner project version 2026-03-11 16:31:53 +01:00
Janez T
088f3d37ef Update Portuguese wizard text 2026-03-11 16:07:38 +01:00
Janez T
7f200e5334 Update translations for all locales 2026-03-11 16:07:09 +01:00
Janez T
ae16417886 Resolve localization and name bugs 2026-03-11 15:54:00 +01:00
Janez T
467371946d Add channel slot detection fix 2026-03-11 15:30:31 +01:00
Janez T
2d23328ad0 Fix empty channel slot handling 2026-03-11 15:10:57 +01:00
Janez T
0dfb84bf4e Fix onboarding layout issues 2026-03-11 15:08:04 +01:00
Janez T
193a193e8a Ensure constant iOS location updates 2026-03-10 19:56:24 +01:00
Janez T
c1d1ec7d0c Enable iOS background GPS updates 2026-03-10 19:50:02 +01:00
Janez T
b415b63b4b Add GPX import export for trails 2026-03-10 13:52:37 +01:00
Janez T
818b9dbfb2 Review MeshCore payload status 2026-03-10 11:05:54 +01:00
Janez T
8085b89c07 Add repeaters map menu entry 2026-03-10 10:38:59 +01:00
Janez T
86771a3c55 Add fullscreen repeater map 2026-03-10 10:25:43 +01:00
Janez T
4b668ac2e9 Fix channel list layout behavior 2026-03-10 09:46:38 +01:00
Janez T
9deb563c05 Align and compact channel cards 2026-03-10 09:45:16 +01:00
Janez T
15ac3e2798 Align channel avatars and compact 2026-03-10 09:13:24 +01:00
Janez T
97addf7ba6 Hide last seen and tweak channels 2026-03-10 09:06:30 +01:00
Janez T
025bd737e7 Group contacts by last seen 2026-03-10 08:54:55 +01:00
Janez T
07b675dec6 Align repeater tile behavior 2026-03-10 08:48:04 +01:00
Janez T
d675ce18dd Use LogRxRouteDecoder in AppProvider 2026-03-10 08:43:58 +01:00
Janez T
a8e31bb2e8 Investigate DEVICE_QUERY timeout 2026-03-09 19:59:29 +01:00
Janez T
3cd96ba961 Fix scan command timeout 2026-03-09 19:53:36 +01:00
Janez T
a98fc6314e Analyze BLE MeshCore scan log 2026-03-09 19:45:17 +01:00
Janez T
ae1ea71f58 Analyze MeshCore BLE scan logs 2026-03-09 19:39:14 +01:00
Janez T
4dff79cd7b Update client ref and deps 2026-03-09 19:33:42 +01:00
Janez T
cbcb329c70 Fix NotificationService platform 2026-03-09 19:23:35 +01:00
Janez T
7abf96abf2 Point web build to meshcore-sar 2026-03-09 19:17:26 +01:00
Janez T
13bf74178c Update iOS TestFlight link 2026-03-09 16:31:42 +01:00
Janez T
66a00c9569 Remove sender name from notification 2026-03-08 21:12:08 +01:00
Janez T
b60fa008eb Fix ios notifications 2026-03-08 21:07:59 +01:00
242 changed files with 66508 additions and 9808 deletions

View File

@@ -40,6 +40,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Cache Android NDK
uses: actions/cache@v4
@@ -95,6 +98,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable Linux desktop
run: flutter config --enable-linux-desktop
@@ -132,6 +138,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable Windows desktop
shell: pwsh
@@ -174,6 +183,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable macOS desktop
run: flutter config --enable-macos-desktop
@@ -181,6 +193,9 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Install macOS native build tools
run: brew install automake libtool
- name: Build macOS release
run: flutter build macos --release
@@ -216,6 +231,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Install dependencies
run: flutter pub get
@@ -272,6 +290,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable web
run: flutter config --enable-web
@@ -280,7 +301,7 @@ jobs:
run: flutter pub get
- name: Build web release
run: flutter build web --release --base-href /meshcore_sar_app/
run: flutter build web --release --base-href /meshcore-sar/
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v3

View File

@@ -27,6 +27,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Install dependencies
run: flutter pub get

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

25
CHANGELOG.md Normal file
View File

@@ -0,0 +1,25 @@
# Changelog
## 2026-03-15 to 2026-03-22
- Device connectivity and routing: added Android USB serial companion support, serial compass support, GPS module controls, per-contact path size selection, and favourites support via firmware flags.
- Location and map intelligence: added RSSI-based repeater trilateration and contact location estimates, isolated MET history, matched the official advert import/discovery flow, and refreshed map presentation by removing contact trails.
- Telemetry and device info: surfaced self telemetry and sensor defaults, opened device info from signal and battery taps, improved signal chip behavior, fixed duplicate telemetry delivery, and restored telemetry refresh behavior.
- Contacts and messaging: added meshcore:// contact sharing and repeater neighbour views, supported MeshCore message links, fixed DM retry behavior, corrected room poster naming, and compacted the recipient selector.
- UI polish and platform upkeep: improved device settings layout, tightened sensor and contact presentation, expanded localization coverage, and refreshed platform dependencies and build numbers.
## Key PRs
- [#26](https://github.com/dz0ny/meshcore-sar/pull/26) Merge pull request #26 from `dz0ny/feat/sensor-telemetry-preview`
## 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

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

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 = 106;
CURRENT_PROJECT_VERSION = 128;
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 = 106;
CURRENT_PROJECT_VERSION = 128;
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 = 106;
CURRENT_PROJECT_VERSION = 128;
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 = 106;
CURRENT_PROJECT_VERSION = 128;
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 = 106;
CURRENT_PROJECT_VERSION = 128;
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 = 106;
CURRENT_PROJECT_VERSION = 128;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

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

View File

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

View File

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

View File

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

@@ -2251,5 +2251,304 @@
"rawDump": "Ακατέργαστα δεδομένα",
"cannotRetryMissingRecipient": "Δεν είναι δυνατή η επανάληψη: λείπουν πληροφορίες παραλήπτη",
"voiceUnavailable": "Η φωνή δεν είναι διαθέσιμη αυτή τη στιγμή",
"requestingVoice": "Αίτηση φωνής"
"requestingVoice": "Αίτηση φωνής",
"device": "συσκευή",
"change": "Αλλαγή",
"wizardOverviewDescription": "Αυτή η εφαρμογή συνδυάζει μηνύματα MeshCore, ενημερώσεις πεδίου SAR, χαρτογράφηση και εργαλεία συσκευής σε ένα σημείο.",
"wizardOverviewFeature1": "Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και μηνύματα καναλιού από την κύρια καρτέλα Μηνύματα.",
"wizardOverviewFeature2": "Μοιραστείτε δείκτες SAR, σχεδιάσεις χάρτη, ηχητικά αποσπάσματα και εικόνες μέσω του mesh.",
"wizardOverviewFeature3": "Συνδεθείτε μέσω BLE ή TCP και στη συνέχεια διαχειριστείτε το συνοδευτικό ραδιόφωνο μέσα από την εφαρμογή.",
"wizardMessagingTitle": "Μηνύματα και αναφορές πεδίου",
"wizardMessagingDescription": "Τα μηνύματα εδώ είναι κάτι περισσότερο από απλό κείμενο. Η εφαρμογή υποστηρίζει ήδη πολλαπλά επιχειρησιακά φορτία και ροές μεταφοράς.",
"wizardMessagingFeature1": "Στείλτε άμεσα μηνύματα, δημοσιεύσεις δωματίου και κίνηση καναλιού από έναν μόνο συντάκτη.",
"wizardMessagingFeature2": "Δημιουργήστε ενημερώσεις SAR και επαναχρησιμοποιήσιμα πρότυπα SAR για συνηθισμένες αναφορές πεδίου.",
"wizardMessagingFeature3": "Μεταφέρετε φωνητικές συνεδρίες και εικόνες, με ένδειξη προόδου και εκτιμήσεις χρόνου εκπομπής στο περιβάλλον.",
"wizardConnectDeviceTitle": "Σύνδεση συσκευής",
"wizardConnectDeviceDescription": "Συνδέστε το ραδιόφωνο MeshCore, επιλέξτε όνομα και εφαρμόστε μια προρύθμιση ραδιοφώνου πριν συνεχίσετε.",
"wizardSetupBadge": "Ρύθμιση",
"wizardOverviewBadge": "Επισκόπηση",
"wizardConnectedToDevice": "Συνδέθηκε με το {deviceName}",
"wizardNoDeviceConnected": "Δεν έχει συνδεθεί ακόμη συσκευή",
"wizardSkipForNow": "Παράλειψη προς το παρόν",
"wizardDeviceNameLabel": "Όνομα συσκευής",
"wizardDeviceNameHelp": "Αυτό το όνομα ανακοινώνεται σε άλλους χρήστες του MeshCore.",
"wizardConfigRegionLabel": "Περιοχή ρύθμισης",
"wizardConfigRegionHelp": "Χρησιμοποιεί την πλήρη επίσημη λίστα προρυθμίσεων MeshCore. Η προεπιλογή είναι EU/UK (Narrow).",
"wizardPresetNote1": "Βεβαιωθείτε ότι η επιλεγμένη προρύθμιση ταιριάζει με τους τοπικούς ραδιοφωνικούς κανονισμούς.",
"wizardPresetNote2": "Η λίστα αντιστοιχεί στην επίσημη ροή προρυθμίσεων του εργαλείου ρύθμισης MeshCore.",
"wizardPresetNote3": "Το EU/UK (Narrow) παραμένει επιλεγμένο από προεπιλογή κατά την αρχική ρύθμιση.",
"wizardSaving": "Αποθήκευση...",
"wizardSaveAndContinue": "Αποθήκευση και συνέχεια",
"wizardEnterDeviceName": "Εισαγάγετε όνομα συσκευής πριν συνεχίσετε.",
"wizardDeviceSetupSaved": "Αποθηκεύτηκε το {deviceName} με {presetName}.",
"wizardNetworkTitle": "Επαφές, δωμάτια και αναμεταδότες",
"wizardNetworkDescription": "Η καρτέλα Επαφές οργανώνει το δίκτυο που ανακαλύπτετε και τις διαδρομές που μαθαίνετε με τον χρόνο.",
"wizardNetworkFeature1": "Ελέγξτε μέλη ομάδας, αναμεταδότες, δωμάτια, κανάλια και εκκρεμείς ανακοινώσεις σε μία λίστα.",
"wizardNetworkFeature2": "Χρησιμοποιήστε smart ping, σύνδεση σε δωμάτια, μαθημένες διαδρομές και εργαλεία επαναφοράς διαδρομών όταν η συνδεσιμότητα γίνεται χαοτική.",
"wizardNetworkFeature3": "Δημιουργήστε κανάλια και διαχειριστείτε προορισμούς δικτύου χωρίς να φύγετε από την εφαρμογή.",
"wizardMapOpsTitle": "Χάρτης, ίχνη και κοινόχρηστη γεωμετρία",
"wizardMapOpsDescription": "Ο χάρτης της εφαρμογής συνδέεται άμεσα με τα μηνύματα, την παρακολούθηση και τα επικαλύμματα SAR αντί να είναι ξεχωριστός προβολέας.",
"wizardMapOpsFeature1": "Παρακολουθήστε τη θέση σας, τις τοποθεσίες της ομάδας και τα ίχνη κίνησης στον χάρτη.",
"wizardMapOpsFeature2": "Ανοίξτε σχεδιάσεις από μηνύματα, προεπισκοπήστε τες επιτόπου και αφαιρέστε τες από τον χάρτη όταν χρειάζεται.",
"wizardMapOpsFeature3": "Χρησιμοποιήστε προβολές χάρτη αναμεταδοτών και κοινόχρηστα επικαλύμματα για να κατανοήσετε την εμβέλεια του δικτύου στο πεδίο.",
"wizardToolsTitle": "Εργαλεία πέρα από τα μηνύματα",
"wizardToolsDescription": "Υπάρχουν περισσότερα εδώ από τις τέσσερις κύριες καρτέλες. Η εφαρμογή περιλαμβάνει επίσης ρυθμίσεις, διαγνωστικά και προαιρετικές ροές αισθητήρων.",
"wizardToolsFeature1": "Ανοίξτε τη ρύθμιση συσκευής για να αλλάξετε παραμέτρους ραδιοφώνου, τηλεμετρία, ισχύ TX και στοιχεία συνοδευτικής συσκευής.",
"wizardToolsFeature2": "Ενεργοποιήστε την καρτέλα Αισθητήρες όταν θέλετε πίνακες παρακολούθησης και γρήγορες ενέργειες ανανέωσης.",
"wizardToolsFeature3": "Χρησιμοποιήστε καταγραφές πακέτων, σάρωση φάσματος και διαγνωστικά προγραμματιστή κατά την αντιμετώπιση προβλημάτων του mesh.",
"contactInSensors": "Στους αισθητήρες",
"contactAddToSensors": "Προσθήκη στους αισθητήρες",
"contactSetPath": "Ορισμός διαδρομής",
"contactAddedToSensors": "Το {contactName} προστέθηκε στους Αισθητήρες",
"contactFailedToClearRoute": "Αποτυχία εκκαθάρισης διαδρομής: {error}",
"contactRouteCleared": "Η διαδρομή εκκαθαρίστηκε",
"contactRouteSet": "Ορίστηκε διαδρομή: {route}",
"contactFailedToSetRoute": "Αποτυχία ορισμού διαδρομής: {error}",
"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,78 +2228,574 @@
"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"
},
"change": "Change",
"@change": {
"description": "Generic change action"
},
"wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.",
"@wizardOverviewDescription": {
"description": "Updated onboarding overview description"
},
"wizardOverviewFeature1": "Send direct, room, and channel messages from the main Messages tab.",
"@wizardOverviewFeature1": {
"description": "Updated onboarding overview feature 1"
},
"wizardOverviewFeature2": "Share SAR markers, map drawings, voice clips, and images over the mesh.",
"@wizardOverviewFeature2": {
"description": "Updated onboarding overview feature 2"
},
"wizardOverviewFeature3": "Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.",
"@wizardOverviewFeature3": {
"description": "Updated onboarding overview feature 3"
},
"wizardMessagingTitle": "Messaging and Field Reports",
"@wizardMessagingTitle": {
"description": "Updated onboarding messaging page title"
},
"wizardMessagingDescription": "Messages are more than plain text here. The app already supports several operational payloads and transfer workflows.",
"@wizardMessagingDescription": {
"description": "Updated onboarding messaging page description"
},
"wizardMessagingFeature1": "Send direct messages, room posts, and channel traffic from one composer.",
"@wizardMessagingFeature1": {
"description": "Updated onboarding messaging feature 1"
},
"wizardMessagingFeature2": "Create SAR updates and reusable SAR templates for common field reports.",
"@wizardMessagingFeature2": {
"description": "Updated onboarding messaging feature 2"
},
"wizardMessagingFeature3": "Transfer voice sessions and images, with progress and airtime estimates in the UI.",
"@wizardMessagingFeature3": {
"description": "Updated onboarding messaging feature 3"
},
"wizardConnectDeviceTitle": "Connect device",
"@wizardConnectDeviceTitle": {
"description": "Onboarding connect device page title"
},
"wizardConnectDeviceDescription": "Connect your MeshCore radio, choose a name, and apply a radio preset before continuing.",
"@wizardConnectDeviceDescription": {
"description": "Onboarding connect device page description"
},
"wizardSetupBadge": "Setup",
"@wizardSetupBadge": {
"description": "Onboarding setup badge label"
},
"wizardOverviewBadge": "Overview",
"@wizardOverviewBadge": {
"description": "Onboarding overview badge label"
},
"wizardConnectedToDevice": "Connected to {deviceName}",
"@wizardConnectedToDevice": {
"description": "Onboarding connection status when connected",
"placeholders": {
"deviceName": {
"type": "String"
}
}
},
"wizardNoDeviceConnected": "No device connected yet",
"@wizardNoDeviceConnected": {
"description": "Onboarding connection status when disconnected"
},
"wizardSkipForNow": "Skip for now",
"@wizardSkipForNow": {
"description": "Onboarding skip current setup step action"
},
"wizardDeviceNameLabel": "Device name",
"@wizardDeviceNameLabel": {
"description": "Onboarding device name field label"
},
"wizardDeviceNameHelp": "This name is advertised to other MeshCore users.",
"@wizardDeviceNameHelp": {
"description": "Onboarding device name field helper text"
},
"wizardConfigRegionLabel": "Config region",
"@wizardConfigRegionLabel": {
"description": "Onboarding config region field label"
},
"wizardConfigRegionHelp": "Uses the full official MeshCore preset list. Default is EU/UK (Narrow).",
"@wizardConfigRegionHelp": {
"description": "Onboarding config region helper text"
},
"wizardPresetNote1": "Make sure the selected preset matches your local radio regulations.",
"@wizardPresetNote1": {
"description": "Onboarding preset note 1"
},
"wizardPresetNote2": "The list matches the official MeshCore config tool preset feed.",
"@wizardPresetNote2": {
"description": "Onboarding preset note 2"
},
"wizardPresetNote3": "EU/UK (Narrow) stays selected by default for onboarding.",
"@wizardPresetNote3": {
"description": "Onboarding preset note 3"
},
"wizardSaving": "Saving...",
"@wizardSaving": {
"description": "Onboarding saving button label"
},
"wizardSaveAndContinue": "Save and continue",
"@wizardSaveAndContinue": {
"description": "Onboarding save and continue button label"
},
"wizardEnterDeviceName": "Enter a device name before continuing.",
"@wizardEnterDeviceName": {
"description": "Onboarding validation when device name is missing"
},
"wizardDeviceSetupSaved": "Saved {deviceName} with {presetName}.",
"@wizardDeviceSetupSaved": {
"description": "Onboarding success message after saving device setup",
"placeholders": {
"deviceName": {
"type": "String"
},
"presetName": {
"type": "String"
}
}
},
"wizardNetworkTitle": "Contacts, Rooms, and Repeaters",
"@wizardNetworkTitle": {
"description": "Updated onboarding network page title"
},
"wizardNetworkDescription": "The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.",
"@wizardNetworkDescription": {
"description": "Updated onboarding network page description"
},
"wizardNetworkFeature1": "Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.",
"@wizardNetworkFeature1": {
"description": "Updated onboarding network feature 1"
},
"wizardNetworkFeature2": "Use smart ping, room login, learned paths, and route reset tools when connectivity gets messy.",
"@wizardNetworkFeature2": {
"description": "Updated onboarding network feature 2"
},
"wizardNetworkFeature3": "Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.",
"@wizardNetworkFeature3": {
"description": "Updated onboarding network feature 3"
},
"wizardMapOpsTitle": "Map, Trails, and Shared Geometry",
"@wizardMapOpsTitle": {
"description": "Updated onboarding map page title"
},
"wizardMapOpsDescription": "The app map is tied directly into messaging, tracking, and SAR overlays instead of being a separate viewer.",
"@wizardMapOpsDescription": {
"description": "Updated onboarding map page description"
},
"wizardMapOpsFeature1": "Track your own position, teammate locations, and movement trails on the map.",
"@wizardMapOpsFeature1": {
"description": "Updated onboarding map feature 1"
},
"wizardMapOpsFeature2": "Open drawings from messages, preview them inline, and remove them from the map when needed.",
"@wizardMapOpsFeature2": {
"description": "Updated onboarding map feature 2"
},
"wizardMapOpsFeature3": "Use repeater map views and shared overlays to understand network reach in the field.",
"@wizardMapOpsFeature3": {
"description": "Updated onboarding map feature 3"
},
"wizardToolsTitle": "Tools Beyond Messaging",
"@wizardToolsTitle": {
"description": "Updated onboarding tools page title"
},
"wizardToolsDescription": "There is more here than the four main tabs. The app also includes configuration, diagnostics, and optional sensor workflows.",
"@wizardToolsDescription": {
"description": "Updated onboarding tools page description"
},
"wizardToolsFeature1": "Open device config to change radio settings, telemetry, TX power, and companion details.",
"@wizardToolsFeature1": {
"description": "Updated onboarding tools feature 1"
},
"wizardToolsFeature2": "Enable the Sensors tab when you want watched sensor dashboards and quick refresh actions.",
"@wizardToolsFeature2": {
"description": "Updated onboarding tools feature 2"
},
"wizardToolsFeature3": "Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.",
"@wizardToolsFeature3": {
"description": "Updated onboarding tools feature 3"
},
"postConnectDiscoveryTitle": "Discover repeaters now?",
"@postConnectDiscoveryTitle": {
"description": "Title for the prompt shown after a successful device connection"
},
"postConnectDiscoveryDescription": "Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.",
"@postConnectDiscoveryDescription": {
"description": "Body text for the prompt shown after a successful device connection"
},
"contactInSensors": "In Sensors",
"@contactInSensors": {
"description": "Contact actions label when already in sensors"
},
"contactAddToSensors": "Add to Sensors",
"@contactAddToSensors": {
"description": "Contact actions label to add to sensors"
},
"contactSetPath": "Set path",
"@contactSetPath": {
"description": "Contact actions label to set route path"
},
"contactAddedToSensors": "{contactName} added to Sensors",
"@contactAddedToSensors": {
"description": "Snackbar after adding contact to sensors",
"placeholders": {
"contactName": {
"type": "String"
}
}
},
"contactFailedToClearRoute": "Failed to clear route: {error}",
"@contactFailedToClearRoute": {
"description": "Error when clearing a contact route fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"contactRouteCleared": "Route cleared",
"@contactRouteCleared": {
"description": "Snackbar after route is cleared"
},
"contactRouteSet": "Route set: {route}",
"@contactRouteSet": {
"description": "Snackbar after route is set",
"placeholders": {
"route": {
"type": "String"
}
}
},
"contactFailedToSetRoute": "Failed to set route: {error}",
"@contactFailedToSetRoute": {
"description": "Error when setting a contact route fails",
"placeholders": {
"error": {
"type": "String"
}
}
},
"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.",
"setRegionScope": "Set region scope",
"regionScope": "Region scope",
"regionScopeNone": "None (global)",
"clearRegionScope": "Clear scope",
"regionScopeWarning": "Only repeaters allowing this region will forward.",
"discoverRegions": "Discover from repeaters",
"discoveringRegions": "Discovering regions...",
"enterRegionName": "Region name (e.g. auckland)",
"noRegionsFound": "No regions found on this repeater.",
"regionScopeSet": "Region scope set to {name}",
"@regionScopeSet": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"regionScopeCleared": "Region scope cleared"
}

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

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

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

@@ -1 +1,209 @@
{}
{
"de": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"el": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"es": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"fr": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"hr": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"it": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"pl": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"pt": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"ru": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"sl": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"tr": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"uk": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
],
"zh": [
"postConnectDiscoveryTitle",
"postConnectDiscoveryDescription",
"setRegionScope",
"regionScope",
"regionScopeNone",
"clearRegionScope",
"regionScopeWarning",
"discoverRegions",
"discoveringRegions",
"enterRegionName",
"noRegionsFound",
"regionScopeSet",
"regionScopeCleared"
]
}

View File

@@ -1,10 +1,14 @@
import 'dart:io' show Platform;
import 'dart:async';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
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';
@@ -19,14 +23,21 @@ import 'services/voice_codec_service.dart';
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());
}
@@ -38,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() {
@@ -51,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();
@@ -62,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();
@@ -70,10 +92,21 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Shows notification if update is available
_checkForUpdates();
// Refresh remote mesh node cache in background when the app starts.
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
setState(() {
_wizardCompleted = wizardCompleted;
_isInitialized = true;
});
if (_pendingNotificationPayload != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final payload = _pendingNotificationPayload;
_pendingNotificationPayload = null;
_handleNotificationTap(payload);
});
}
}
/// Handle notification tap
@@ -82,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
}
@@ -158,7 +208,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
/// Shows notification if update is available
Future<void> _checkForUpdates() async {
// Only check for updates on Android
if (!Platform.isAndroid) {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[UpdateChecker] Skipping update check (not Android)');
return;
}
@@ -235,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(
@@ -287,6 +344,39 @@ 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>(),
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
appProvider: appProvider,
),
),
],
child: _buildMaterialApp(),
);
@@ -297,6 +387,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}',
),
@@ -324,7 +415,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
if (Platform.isAndroid) {
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return SafeArea(
left: false,
right: false,

View File

@@ -57,9 +57,7 @@ class Channel {
} else {
// Normal channel: require explicit secret
if (explicitSecret == null || explicitSecret.length != 16) {
throw ArgumentError(
'Normal channels require a 16-byte secret',
);
throw ArgumentError('Normal channels require a 16-byte secret');
}
return Channel(
index: index,
@@ -78,6 +76,18 @@ class Channel {
return Uint8List.fromList(digest.bytes.sublist(0, 16));
}
static bool isHashChannelName(String channelName) {
return channelName.trim().startsWith('#');
}
static String pskBase64ForHashChannelName(String channelName) {
final normalized = channelName.trim();
if (!isHashChannelName(normalized)) {
throw ArgumentError('Only #channels can export derived psk_base64');
}
return base64.encode(_generateHashChannelSecret(normalized));
}
/// Create the default public channel (channel 0)
/// Uses the well-known pre-shared key from MeshCore
factory Channel.publicChannel() {
@@ -85,8 +95,22 @@ class Channel {
index: 0,
name: 'Public Channel',
secret: Uint8List.fromList([
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
0x8b,
0x33,
0x87,
0xe9,
0xc5,
0xcd,
0xea,
0x6a,
0xc9,
0xe5,
0xed,
0xba,
0xa1,
0x15,
0xcd,
0x72,
]),
flags: null,
);
@@ -95,6 +119,9 @@ class Channel {
/// Check if this is a hash-based channel (name starts with '#')
bool get isHashChannel => name.startsWith('#');
/// Base64-encoded PSK for sharing with firmware CLI and related tooling.
String get pskBase64 => base64.encode(secret);
/// Display name for the channel
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
String get displayName {
@@ -131,12 +158,7 @@ class Channel {
}
/// Create a copy with modified fields
Channel copyWith({
int? index,
String? name,
Uint8List? secret,
int? flags,
}) {
Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
return Channel(
index: index ?? this.index,
name: name ?? this.name,

View File

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

View File

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

View File

@@ -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 (Serial)';
}
}
@@ -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 'Direct serial connection to MeshCore device';
}
}
}
@@ -66,6 +66,12 @@ 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? autoAddMaxHops;
final int? radioFreq;
final int? radioBw;
final int? radioSf;
@@ -90,6 +96,7 @@ class DeviceInfo {
// Repeat mode (firmware v9+)
final bool? clientRepeat;
final int? pathHashMode;
final bool? supportsSpectrumScan;
final int? spectrumScanMinKhz;
final int? spectrumScanMaxKhz;
@@ -113,6 +120,12 @@ class DeviceInfo {
this.advLat,
this.advLon,
this.manualAddContacts,
this.autoAddUsers,
this.autoAddRepeaters,
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.autoAddMaxHops,
this.radioFreq,
this.radioBw,
this.radioSf,
@@ -129,6 +142,7 @@ class DeviceInfo {
this.manufacturerModel,
this.semanticVersion,
this.clientRepeat,
this.pathHashMode,
this.supportsSpectrumScan,
this.spectrumScanMinKhz,
this.spectrumScanMaxKhz,
@@ -239,6 +253,12 @@ class DeviceInfo {
int? advLat,
int? advLon,
bool? manualAddContacts,
bool? autoAddUsers,
bool? autoAddRepeaters,
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? autoAddMaxHops,
int? radioFreq,
int? radioBw,
int? radioSf,
@@ -255,6 +275,7 @@ class DeviceInfo {
String? manufacturerModel,
String? semanticVersion,
bool? clientRepeat,
int? pathHashMode,
bool? supportsSpectrumScan,
int? spectrumScanMinKhz,
int? spectrumScanMaxKhz,
@@ -278,6 +299,13 @@ 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,
autoAddMaxHops: autoAddMaxHops ?? this.autoAddMaxHops,
radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf,
@@ -294,6 +322,7 @@ class DeviceInfo {
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
semanticVersion: semanticVersion ?? this.semanticVersion,
clientRepeat: clientRepeat ?? this.clientRepeat,
pathHashMode: pathHashMode ?? this.pathHashMode,
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,

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

@@ -1,20 +1,34 @@
enum PathRecordSource { learned, observed }
class PathRecord {
final List<int> pathBytes;
final int hopCount;
final int hashSize;
final PathRecordSource source;
final int successCount;
final int failureCount;
final int lastRoundTripTimeMs;
final DateTime lastUsedAt;
final DateTime? lastSucceededAt;
final double? senderLatitude;
final double? senderLongitude;
final double? recipientLatitude;
final double? recipientLongitude;
const PathRecord({
required this.pathBytes,
required this.hopCount,
required this.hashSize,
required this.source,
required this.successCount,
required this.failureCount,
required this.lastRoundTripTimeMs,
required this.lastUsedAt,
required this.lastSucceededAt,
required this.senderLatitude,
required this.senderLongitude,
required this.recipientLatitude,
required this.recipientLongitude,
});
String get signature =>
@@ -27,19 +41,31 @@ class PathRecord {
List<int>? pathBytes,
int? hopCount,
int? hashSize,
PathRecordSource? source,
int? successCount,
int? failureCount,
int? lastRoundTripTimeMs,
DateTime? lastUsedAt,
DateTime? lastSucceededAt,
double? senderLatitude,
double? senderLongitude,
double? recipientLatitude,
double? recipientLongitude,
}) {
return PathRecord(
pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize,
source: source ?? this.source,
successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
lastSucceededAt: lastSucceededAt ?? this.lastSucceededAt,
senderLatitude: senderLatitude ?? this.senderLatitude,
senderLongitude: senderLongitude ?? this.senderLongitude,
recipientLatitude: recipientLatitude ?? this.recipientLatitude,
recipientLongitude: recipientLongitude ?? this.recipientLongitude,
);
}
@@ -48,10 +74,16 @@ class PathRecord {
'path_bytes': pathBytes,
'hop_count': hopCount,
'hash_size': hashSize,
'source': source.name,
'success_count': successCount,
'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt.toIso8601String(),
'last_succeeded_at': lastSucceededAt?.toIso8601String(),
'sender_latitude': senderLatitude,
'sender_longitude': senderLongitude,
'recipient_latitude': recipientLatitude,
'recipient_longitude': recipientLongitude,
};
}
@@ -62,12 +94,23 @@ class PathRecord {
.toList(),
hopCount: json['hop_count'] as int? ?? 0,
hashSize: json['hash_size'] as int? ?? 1,
source: PathRecordSource.values.firstWhere(
(value) => value.name == (json['source'] as String? ?? 'learned'),
orElse: () => PathRecordSource.learned,
),
successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0,
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
lastUsedAt:
DateTime.tryParse(json['last_used_at'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
lastSucceededAt: DateTime.tryParse(
json['last_succeeded_at'] as String? ?? '',
),
senderLatitude: (json['sender_latitude'] as num?)?.toDouble(),
senderLongitude: (json['sender_longitude'] as num?)?.toDouble(),
recipientLatitude: (json['recipient_latitude'] as num?)?.toDouble(),
recipientLongitude: (json['recipient_longitude'] as num?)?.toDouble(),
);
}
}
@@ -163,6 +206,10 @@ class ContactPathHistory {
};
}
List<PathRecord> get observedPaths => directPaths
.where((record) => record.source == PathRecordSource.observed)
.toList();
factory ContactPathHistory.fromJson(
String contactPublicKeyHex,
Map<String, dynamic> json,

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

View File

@@ -7,7 +7,8 @@ class ChannelsProvider with ChangeNotifier {
int _selectedChannelIndex = 0; // Default to public channel
/// Get all channels
List<Channel> get channels => _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
List<Channel> get channels =>
_channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
/// Get a specific channel by index
Channel? getChannel(int index) => _channels[index];
@@ -54,12 +55,12 @@ class ChannelsProvider with ChangeNotifier {
void removeChannel(int index) {
if (_channels.containsKey(index)) {
_channels.remove(index);
// If the deleted channel was selected, switch to public channel
if (_selectedChannelIndex == index) {
_selectedChannelIndex = 0;
}
notifyListeners();
}
}
@@ -96,6 +97,13 @@ class ChannelsProvider with ChangeNotifier {
notifyListeners();
}
/// Clear runtime channel state before a live device sync begins.
void prepareForDeviceSync() {
_channels.clear();
_selectedChannelIndex = 0;
notifyListeners();
}
/// Check if channels have been loaded
bool get hasChannels => _channels.isNotEmpty;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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
@@ -11,6 +14,8 @@ enum DrawingMode { none, line, rectangle, measure }
/// Provider for managing map drawings
class DrawingProvider with ChangeNotifier {
static const String _storageKey = 'map_drawings';
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
static const String _showSarMarkersKey = 'map_show_sar_markers';
// Drawing state
DrawingMode _drawingMode = DrawingMode.none;
@@ -20,6 +25,7 @@ class DrawingProvider with ChangeNotifier {
// Completed drawings
final List<MapDrawing> _drawings = [];
bool _isInitialized = false;
// In-progress drawing
MapDrawing? _currentDrawing;
@@ -30,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);
@@ -45,19 +57,62 @@ 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;
/// Initialize and load saved drawings
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
@@ -77,15 +132,34 @@ class DrawingProvider with ChangeNotifier {
}
/// Toggle visibility of received drawings
void toggleReceivedDrawings() {
Future<void> toggleReceivedDrawings() async {
_showReceivedDrawings = !_showReceivedDrawings;
notifyListeners();
await _savePreferences();
}
/// Toggle visibility of SAR markers
void toggleSarMarkers() {
Future<void> toggleSarMarkers() async {
_showSarMarkers = !_showSarMarkers;
notifyListeners();
await _savePreferences();
}
Future<void> _loadPreferences() async {
final prefs = await SharedPreferences.getInstance();
_showReceivedDrawings =
prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true;
_showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true;
notifyListeners();
}
Future<void> _savePreferences() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
_scopedKey(_showReceivedDrawingsKey),
_showReceivedDrawings,
);
await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers);
}
/// Start drawing a line
@@ -116,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);
@@ -159,6 +237,10 @@ class DrawingProvider with ChangeNotifier {
? _rectangleStartPoint!.longitude
: endPoint.longitude,
),
coordinateSpace: _activeCoordinateSpace,
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
? _activeMapId
: null,
);
notifyListeners();
}
@@ -195,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);
@@ -216,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);
@@ -225,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);
}
@@ -281,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');
}
@@ -291,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();
@@ -318,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) {
@@ -355,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(
@@ -369,6 +473,8 @@ class DrawingProvider with ChangeNotifier {
isShared: drawing.isShared,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
}
return drawing;
@@ -383,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
@@ -407,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(
@@ -421,6 +547,8 @@ class DrawingProvider with ChangeNotifier {
isShared: true,
isSent: drawing.isSent,
isHidden: drawing.isHidden,
coordinateSpace: drawing.coordinateSpace,
mapId: drawing.mapId,
);
}
@@ -448,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(
@@ -462,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.
/// Sequence: 2 direct attempts, flood, then last successful route.
static const int maxRetryAttemptsWithPath = 3;
/// 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

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

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../services/profiles_feature_service.dart';
import 'helpers/raw_session_retransmit.dart';
import '../utils/image_message_parser.dart';
@@ -75,6 +76,8 @@ class ImageProvider with ChangeNotifier {
_restore();
}
String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey);
// ── Accessors ────────────────────────────────────────────────────────────
ImageSession? session(String sessionId) => _sessions[sessionId];
@@ -292,18 +295,22 @@ class ImageProvider with ChangeNotifier {
// ── Persistence ──────────────────────────────────────────────────────────
Future<void> clearAll() async {
_sessions.clear();
_outgoing.clear();
_ignoredIncomingSessions.clear();
_resetInMemoryState();
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_storageKey);
await prefs.remove(_scopedStorageKey());
} catch (e) {
debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
}
}
Future<void> reloadProfileScopedState() async {
_resetInMemoryState();
await _restore();
notifyListeners();
}
void _evictExpiredOutgoing() {
final now = DateTime.now();
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
@@ -343,7 +350,7 @@ class ImageProvider with ChangeNotifier {
)
.toList(),
};
await prefs.setString(_storageKey, jsonEncode(payload));
await prefs.setString(_scopedStorageKey(), jsonEncode(payload));
} catch (e) {
debugPrint('❌ [ImageProvider] Failed to persist: $e');
}
@@ -352,7 +359,7 @@ class ImageProvider with ChangeNotifier {
Future<void> _restore() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey);
final raw = prefs.getString(_scopedStorageKey());
if (raw == null || raw.isEmpty) return;
final parsed = jsonDecode(raw) as Map<String, dynamic>;
@@ -428,6 +435,12 @@ class ImageProvider with ChangeNotifier {
debugPrint('❌ [ImageProvider] Failed to restore: $e');
}
}
void _resetInMemoryState() {
_sessions.clear();
_outgoing.clear();
_ignoredIncomingSessions.clear();
}
}
class _OutgoingSession {

View File

@@ -1,24 +1,46 @@
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;
@@ -31,28 +53,27 @@ class MapProvider with ChangeNotifier {
bool _showPlaceNamesOverlay = false;
bool _showMunicipalityBordersOverlay = false;
// Contact trail toggles
bool _showAllContactTrails = true; // Default to showing all contact trails
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;
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
// Trail getters
MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace;
String? get targetMapId => _targetMapId;
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;
@@ -65,68 +86,138 @@ 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;
@@ -142,9 +233,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) {
@@ -152,37 +359,7 @@ class MapProvider with ChangeNotifier {
notifyListeners();
}
/// Toggle path visibility for a contact
void toggleContactPath(String publicKeyHex) {
if (_visibleContactPaths.contains(publicKeyHex)) {
_visibleContactPaths.remove(publicKeyHex);
} else {
_visibleContactPaths.add(publicKeyHex);
}
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();
}
@@ -195,22 +372,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;
@@ -223,13 +400,11 @@ class MapProvider with ChangeNotifier {
}
}
/// Toggle trail visibility
void toggleTrailVisibility() {
_isTrailVisible = !_isTrailVisible;
notifyListeners();
}
/// Clear the current trail
void clearCurrentTrail() {
if (_currentTrail != null) {
_currentTrail = null;
@@ -237,197 +412,344 @@ 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();
}
/// Save overlay state to SharedPreferences
Future<void> _loadInitialState() async {
await Future.wait([
loadOverlayState(),
loadRepeaterVisibilitySettings(),
_loadCustomMapState(),
_removeDeprecatedSettings(),
]);
}
Future<void> _removeDeprecatedSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_scopedKey('map_show_all_contact_trails'));
}
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;
Future<void> setHideRepeatersOnMap(bool hide) async {
if (_hideRepeatersOnMap == hide) return;
_hideRepeatersOnMap = hide;
notifyListeners();
await _saveTrailSettings();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap);
}
/// Load trail settings from SharedPreferences
Future<void> loadTrailSettings() async {
Future<void> loadRepeaterVisibilitySettings() async {
final prefs = await SharedPreferences.getInstance();
_showAllContactTrails = prefs.getBool('map_show_all_contact_trails') ?? true; // Default to true (show all)
_hideRepeatersOnMap =
prefs.getBool(_scopedKey('map_hide_repeaters')) ?? false;
notifyListeners();
}
/// Save trail settings to SharedPreferences
Future<void> _saveTrailSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
}
/// 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,
'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;
_hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false;
notifyListeners();
}
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
}

File diff suppressed because it is too large Load Diff

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';
@@ -12,9 +14,23 @@ enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
class SensorsProvider with ChangeNotifier {
static const Duration _successStateRetention = Duration(minutes: 1);
static const Duration selfAutoRefreshInterval = Duration(seconds: 30);
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 +39,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 +56,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 +82,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 +117,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 +169,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 +190,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 +202,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 +276,118 @@ 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);
}
bool _isRefreshDueForInterval(
String publicKeyHex, {
required Duration interval,
required DateTime refreshTime,
}) {
if (interval <= Duration.zero) {
return false;
}
final lastRefreshAt = _lastRefreshAttemptAt[publicKeyHex];
return lastRefreshAt == null ||
refreshTime.difference(lastRefreshAt) >= interval;
}
Future<void> toggleMetric(
String publicKeyHex,
String fieldKey,
@@ -140,8 +397,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 +415,9 @@ class SensorsProvider with ChangeNotifier {
visibleFields.remove(fieldKey);
}
await _persistVisibleMetrics();
if (shouldPersistOrder) {
await _persistMetricOrder();
}
notifyListeners();
}
@@ -166,11 +435,84 @@ 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);
Contact? selfContact(
ContactsProvider contactsProvider,
ConnectionProvider connectionProvider,
) {
final selfKey = connectionProvider.deviceInfo.publicKey;
if (selfKey == null || selfKey.isEmpty) {
return null;
}
final existing = contactsProvider.findContactByKey(
Uint8List.fromList(selfKey),
);
final selfTelemetry = contactsProvider.selfTelemetry;
if (existing != null) {
return selfTelemetry == null
? existing
: existing.copyWith(telemetry: selfTelemetry);
}
return _buildSelfCandidate(connectionProvider, telemetry: selfTelemetry);
}
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,43 +520,183 @@ 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();
}
List<Contact> availableCandidates(ContactsProvider contactsProvider) {
Future<void> reorderSensors(int oldIndex, int newIndex) async {
if (_watchedSensorKeys.length < 2) {
return;
}
if (oldIndex < 0 ||
oldIndex >= _watchedSensorKeys.length ||
newIndex < 0 ||
newIndex > _watchedSensorKeys.length) {
return;
}
var targetIndex = newIndex;
if (oldIndex < targetIndex) {
targetIndex -= 1;
}
if (oldIndex == targetIndex) {
return;
}
final movedKey = _watchedSensorKeys.removeAt(oldIndex);
_watchedSensorKeys.insert(targetIndex, movedKey);
await _persistWatchedSensors();
notifyListeners();
}
List<Contact> availableCandidates(
ContactsProvider contactsProvider, {
ConnectionProvider? connectionProvider,
}) {
final candidates = <Contact>[
...contactsProvider.chatContacts,
...contactsProvider.repeaters,
...contactsProvider.sensorContacts,
];
// Add "myself" (own device) as a candidate if it has a public key
if (connectionProvider != null) {
final self = selfContact(contactsProvider, connectionProvider);
if (self != null &&
!candidates.any((c) => c.publicKeyHex == self.publicKeyHex)) {
candidates.insert(0, self);
}
}
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
return candidates;
}
List<String> displaySensorKeys({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) {
if (_watchedSensorKeys.isNotEmpty) {
return List<String>.unmodifiable(_watchedSensorKeys);
}
final self = selfContact(contactsProvider, connectionProvider);
if (self == null) {
return const <String>[];
}
return <String>[self.publicKeyHex];
}
String? displaySelfKey({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) {
final self = selfContact(contactsProvider, connectionProvider);
if (self == null || _watchedSensorKeys.contains(self.publicKeyHex)) {
return null;
}
return self.publicKeyHex;
}
Contact? contactForDisplay(
String publicKeyHex, {
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) {
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
return entry;
}
}
final self = selfContact(contactsProvider, connectionProvider);
if (self?.publicKeyHex == publicKeyHex) {
return self;
}
return null;
}
Future<void> refreshAll({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) async {
if (_isRefreshingAll || _watchedSensorKeys.isEmpty) {
if (_isRefreshingAll) {
return;
}
final self = selfContact(contactsProvider, connectionProvider);
final keysToRefresh = <String>[
if (self != null) self.publicKeyHex,
..._watchedSensorKeys.where(
(key) => self == null || key != self.publicKeyHex,
),
];
if (keysToRefresh.isEmpty) {
return;
}
@@ -222,7 +704,7 @@ class SensorsProvider with ChangeNotifier {
notifyListeners();
try {
for (final key in _watchedSensorKeys) {
for (final key in keysToRefresh) {
await refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
@@ -239,15 +721,20 @@ class SensorsProvider with ChangeNotifier {
required String publicKeyHex,
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
DateTime? requestedAt,
}) async {
Contact? contact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
contact = entry;
break;
}
if (stateFor(publicKeyHex) == SensorRefreshState.refreshing) {
return;
}
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
final contact = contactForDisplay(
publicKeyHex,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
if (contact == null) {
_setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
return;
@@ -266,6 +753,48 @@ 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).toList();
final self = selfContact(contactsProvider, connectionProvider);
if (self != null &&
!dueKeys.contains(self.publicKeyHex) &&
_isRefreshDueForInterval(
self.publicKeyHex,
interval: selfAutoRefreshInterval,
refreshTime: refreshTime,
)) {
dueKeys.insert(0, self.publicKeyHex);
}
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>[];
@@ -297,4 +826,29 @@ class SensorsProvider with ChangeNotifier {
_refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
notifyListeners();
}
Contact? _buildSelfCandidate(
ConnectionProvider connectionProvider, {
ContactTelemetry? telemetry,
}) {
final deviceInfo = connectionProvider.deviceInfo;
final selfKey = deviceInfo.publicKey;
if (selfKey == null || selfKey.isEmpty) {
return null;
}
return Contact(
publicKey: Uint8List.fromList(selfKey),
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: deviceInfo.selfName ?? 'My Device',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: deviceInfo.advLat ?? 0,
advLon: deviceInfo.advLon ?? 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: telemetry,
);
}
}

View File

@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../services/profiles_feature_service.dart';
import 'helpers/raw_session_retransmit.dart';
import '../utils/voice_message_parser.dart';
import '../services/voice_codec_service.dart';
@@ -91,6 +93,9 @@ class VoiceProvider with ChangeNotifier {
_restorePersistedVoiceData();
}
String _storageKey() =>
ProfileStorageScope.scopedKey(_voiceSessionsStorageKey);
// ── Session accessors ────────────────────────────────────────────────────
VoiceSession? session(String sessionId) => _sessions[sessionId];
@@ -299,11 +304,15 @@ class VoiceProvider with ChangeNotifier {
);
try {
final pcm = await _codec.decodePackets(session.packets, session.mode);
final decodedPcm = await _codec.decodePackets(
session.packets,
session.mode,
);
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
_playingSessionId = sessionId;
notifyListeners();
await _player.play(pcm);
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
} catch (e, st) {
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
if (_playingSessionId == sessionId) {
@@ -319,20 +328,27 @@ class VoiceProvider with ChangeNotifier {
notifyListeners();
}
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
return pcm;
}
Future<void> clearStoredVoiceData() async {
_sessions.clear();
_outgoingSessions.clear();
_ignoredIncomingSessions.clear();
_playingSessionId = null;
await _resetInMemoryState();
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_voiceSessionsStorageKey);
await prefs.remove(_storageKey());
} catch (e) {
debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e');
}
}
Future<void> reloadProfileScopedState() async {
await _resetInMemoryState();
await _restorePersistedVoiceData();
notifyListeners();
}
Future<void> _persistVoiceData() async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -356,7 +372,7 @@ class VoiceProvider with ChangeNotifier {
)
.toList(),
};
await prefs.setString(_voiceSessionsStorageKey, jsonEncode(payload));
await prefs.setString(_storageKey(), jsonEncode(payload));
} catch (e) {
debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e');
}
@@ -365,7 +381,7 @@ class VoiceProvider with ChangeNotifier {
Future<void> _restorePersistedVoiceData() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_voiceSessionsStorageKey);
final raw = prefs.getString(_storageKey());
if (raw == null || raw.isEmpty) return;
final parsed = jsonDecode(raw) as Map<String, dynamic>;
@@ -428,6 +444,16 @@ class VoiceProvider with ChangeNotifier {
}
}
Future<void> _resetInMemoryState() async {
if (_playingSessionId != null || _player.isPlaying) {
await _player.stop();
}
_sessions.clear();
_outgoingSessions.clear();
_ignoredIncomingSessions.clear();
_playingSessionId = null;
}
@override
void dispose() {
_playerEventsSub.cancel();

View File

@@ -0,0 +1,394 @@
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 {
final String? initialAdvert;
const AddContactScreen({super.key, this.initialAdvert});
@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();
if (widget.initialAdvert != null &&
widget.initialAdvert!.trim().isNotEmpty) {
_advertController.text = widget.initialAdvert!.trim();
} else {
_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),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -6,24 +8,31 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:vibration/vibration.dart';
import '../providers/connection_provider.dart';
import '../providers/app_provider.dart';
import '../models/device_info.dart' show ConnectionMode;
import '../models/device_info.dart' show ConnectionMode, DeviceInfo;
import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart';
import '../theme/app_theme.dart';
import 'messages_tab.dart';
import 'contacts_tab.dart';
import 'discovery_screen.dart';
import 'sensors_tab.dart';
import 'map_tab.dart';
import 'repeaters_map_screen.dart';
import 'settings_screen.dart';
import 'device_config_screen.dart';
import 'packet_log_screen.dart';
import 'spectrum_scan_screen.dart';
import 'live_traffic_screen.dart';
import 'profiles_screen.dart';
import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart';
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 }
@@ -53,14 +62,15 @@ 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;
bool _isDeveloperModeEnabled = false;
bool _isMapEnabled = true;
bool _isContactsEnabled = true;
bool _isSensorsEnabled = false;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _lastProfileDeviceKey;
List<_HomeTab> get _enabledTabs {
return [
@@ -84,6 +94,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;
@@ -92,7 +104,7 @@ class _HomeScreenState extends State<HomeScreen>
// Initialize synchronously so first build always has a valid controller.
_initTabController();
_loadRxTxPreference();
_loadDeveloperModePreference();
MeshMapNodesService.syncInBackgroundIfStale();
// Show permission dialog after the first frame if needed
if (widget.shouldShowPermissionDialog) {
@@ -100,6 +112,10 @@ class _HomeScreenState extends State<HomeScreen>
_showPermissionDialog();
});
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleConnectionProviderChanged();
});
}
void _initTabController() {
@@ -195,7 +211,6 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) {
case _HomeTab.messages:
context.read<MessagesProvider>().markAllAsRead();
break;
case _HomeTab.contacts:
context.read<ContactsProvider>().markAllAsViewed();
@@ -216,31 +231,28 @@ class _HomeScreenState extends State<HomeScreen>
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state;
_syncFastLocationUiState();
}
Future<void> _loadRxTxPreference() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true;
});
void _handleConnectionProviderChanged() {
final deviceKey = ProfileDeviceKeyResolver.resolve(
deviceInfo: _connectionProvider.deviceInfo,
connectionMode: _connectionProvider.connectionMode,
);
if (_lastProfileDeviceKey == deviceKey) {
return;
}
}
Future<void> _loadDeveloperModePreference() async {
final isEnabled = await DeveloperModeService.isEnabled();
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = isEnabled;
});
_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);
@@ -249,6 +261,32 @@ class _HomeScreenState extends State<HomeScreen>
super.dispose();
}
void _openLiveTraffic(ConnectionProvider provider) {
openLiveTrafficScreen(context, provider);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state;
_syncFastLocationUiState();
if (state == AppLifecycleState.resumed) {
MeshMapNodesService.syncInBackgroundIfStale();
}
}
Future<void> _loadRxTxPreference() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
setState(() {
_showRxTxIndicators =
prefs.getBool(
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
) ??
true;
});
}
}
void _showPermissionDialog() {
if (!mounted) return;
@@ -344,7 +382,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),
@@ -370,7 +408,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),
@@ -384,41 +422,184 @@ class _HomeScreenState extends State<HomeScreen>
);
}
Widget _buildActivityBadge({
required String label,
required int count,
required bool isActive,
required Color activeColor,
}) {
final color = isActive ? activeColor : Colors.grey;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
void _showDeviceInfoSheet(BuildContext context, DeviceInfo deviceInfo) {
// Request self telemetry so it's fresh
context.read<ConnectionProvider>().requestSelfTelemetry();
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(width: 6),
Text(
'$label:$count',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
builder: (context) => Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final telemetry = contactsProvider.selfTelemetry;
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
Text(
deviceInfo.selfName ?? deviceInfo.deviceName ?? 'Device',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
_deviceInfoRow(
context,
Icons.bluetooth,
'BLE Signal',
deviceInfo.signalRssi != null
? '${deviceInfo.signalRssi} dBm'
: 'N/A',
),
if (deviceInfo.batteryPercent != null)
_deviceInfoRow(
context,
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
'Battery',
'${deviceInfo.batteryPercent!.round()}%',
),
if (deviceInfo.batteryMilliVolts != null)
_deviceInfoRow(
context,
Icons.bolt,
'Voltage',
'${(deviceInfo.batteryMilliVolts! / 1000).toStringAsFixed(2)}V',
),
if (deviceInfo.storageUsedKb != null &&
deviceInfo.storageTotalKb != null)
_deviceInfoRow(
context,
Icons.storage,
'Storage',
'${deviceInfo.storageUsedKb} / ${deviceInfo.storageTotalKb} KB',
),
if (deviceInfo.firmwareVersion != null)
_deviceInfoRow(
context,
Icons.system_update,
'Firmware',
'v${deviceInfo.firmwareVersion}',
),
if (deviceInfo.radioFreq != null)
_deviceInfoRow(
context,
Icons.radio,
'Frequency',
'${(deviceInfo.radioFreq! / 1000).toStringAsFixed(3)} MHz',
),
if (deviceInfo.txPower != null)
_deviceInfoRow(
context,
Icons.power,
'TX Power',
'${deviceInfo.txPower} dBm',
),
if (telemetry != null) ...[
const Divider(height: 24),
Text(
'Self Telemetry',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (telemetry.temperature != null)
_deviceInfoRow(
context,
Icons.thermostat,
'Temperature',
'${telemetry.temperature!.toStringAsFixed(1)}°C',
),
if (telemetry.humidity != null)
_deviceInfoRow(
context,
Icons.water_drop,
'Humidity',
'${telemetry.humidity!.toStringAsFixed(1)}%',
),
if (telemetry.pressure != null)
_deviceInfoRow(
context,
Icons.compress,
'Pressure',
'${telemetry.pressure!.toStringAsFixed(1)} hPa',
),
if (telemetry.gpsLocation != null)
_deviceInfoRow(
context,
Icons.gps_fixed,
'GPS',
'${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}',
),
],
],
),
),
);
},
),
);
}
Widget _deviceInfoRow(
BuildContext context,
IconData icon,
String label,
String value,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
),
Text(
value,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
],
),
);
}
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: 2.5,
height: 4.0 + (i * 2),
decoration: BoxDecoration(
color: i < activeBars ? color : inactive,
borderRadius: BorderRadius.circular(1.5),
),
),
],
],
);
}
Widget _buildCompactActivityIndicator({
required bool rxActive,
required bool txActive,
@@ -556,12 +737,10 @@ class _HomeScreenState extends State<HomeScreen>
}
}
void _showConnectionDialog(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
Future<void> _showConnectionDialog(BuildContext context) async {
await showConnectionDialogFlow(
context,
backgroundColor: Colors.transparent,
builder: (context) => const ConnectionDialog(),
);
}
@@ -575,6 +754,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;
@@ -599,7 +781,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,
);
@@ -611,39 +793,109 @@ 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(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
const SizedBox(width: 8),
const Text('Spectrum Scan'),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const SpectrumScanScreen(),
),
);
});
},
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar_outlined),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.liveTraffic),
],
),
);
}
onTap: () {
final navigator = Navigator.of(context);
final provider = context.read<ConnectionProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (_) => LiveTrafficScreen.fromProvider(
provider,
openPacketLogs: () {
navigator.push(
MaterialPageRoute(
builder: (_) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
),
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.router_outlined),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.repeatersMap),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const RepeatersMapScreen(),
fullscreenDialog: true,
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.person_search),
const SizedBox(width: 8),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final pendingCount =
contactsProvider.pendingAdverts.length;
return Text(
pendingCount > 0
? 'Discovery ($pendingCount)'
: 'Discovery',
);
},
),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => const DiscoveryScreen(),
),
);
});
},
),
);
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
SizedBox(width: 8),
Text(AppLocalizations.of(context)!.settings),
],
),
@@ -662,12 +914,36 @@ class _HomeScreenState extends State<HomeScreen>
),
);
_loadRxTxPreference();
_loadDeveloperModePreference();
});
},
),
);
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;
},
),
@@ -679,6 +955,9 @@ class _HomeScreenState extends State<HomeScreen>
switch (tab) {
case _HomeTab.messages:
return MessagesTab(
isActive:
_currentTab == _HomeTab.messages &&
_lifecycleState == AppLifecycleState.resumed,
onNavigateToMap: _isMapEnabled
? () => _navigateToTab(_HomeTab.map)
: null,
@@ -691,9 +970,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;
@@ -738,7 +1022,7 @@ class _HomeScreenState extends State<HomeScreen>
Icons.message,
unreadCount,
),
text: AppLocalizations.of(context)!.messages,
text: 'Chat',
);
case _HomeTab.contacts:
return Tab(
@@ -750,7 +1034,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:
@@ -775,19 +1059,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),
@@ -798,22 +1098,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),
),
),
),
@@ -821,7 +1118,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,
@@ -880,50 +1177,45 @@ class _HomeScreenState extends State<HomeScreen>
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isTcpConnected
? Icons.wifi_rounded
: Icons.bluetooth_connected_rounded,
size: 13,
color: signalColor,
),
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,
),
),
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () =>
_showDeviceInfoSheet(context, deviceInfo),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
isTcpConnected
? Icons.wifi_rounded
: Icons.bluetooth_connected_rounded,
size: 13,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
color: signalColor,
),
const SizedBox(width: 4),
SizedBox(
width: 30,
child: Text(
if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4),
_buildMiniSignalBars(
activeBars:
BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!,
),
color: signalColor,
),
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
size: 13,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
const SizedBox(width: 2),
Text(
'${deviceInfo.batteryPercent!.round()}%',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
@@ -933,9 +1225,9 @@ class _HomeScreenState extends State<HomeScreen>
),
),
),
),
],
],
],
),
),
],
),
@@ -1022,6 +1314,7 @@ class _HomeScreenState extends State<HomeScreen>
SizedBox(width: isTight ? 8 : 12),
if (_showRxTxIndicators)
GestureDetector(
onTap: () => _openLiveTraffic(provider),
onLongPress: () {
Navigator.push(
context,
@@ -1031,44 +1324,13 @@ class _HomeScreenState extends State<HomeScreen>
),
);
},
child: isTight
? _buildCompactActivityIndicator(
rxActive: provider.rxActivity,
txActive: provider.txActivity,
)
: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildActivityBadge(
label: 'RX',
count: provider.rxPacketCount,
isActive: provider.rxActivity,
activeColor: Colors.green,
),
const SizedBox(height: 6),
_buildActivityBadge(
label: 'TX',
count: provider.txPacketCount,
isActive: provider.txActivity,
activeColor: Colors.blue,
),
],
),
),
child: _buildCompactActivityIndicator(
rxActive: provider.rxActivity,
txActive: provider.txActivity,
),
)
else
SizedBox(width: isTight ? 24 : 74),
const SizedBox(width: 24),
],
);
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,601 +0,0 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:provider/provider.dart';
import '../models/device_info.dart';
import '../providers/connection_provider.dart';
import '../widgets/device/spectrum_scan_panel.dart';
class SpectrumScanScreen extends StatefulWidget {
const SpectrumScanScreen({super.key});
@override
State<SpectrumScanScreen> createState() => _SpectrumScanScreenState();
}
class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
static const List<String> _bandwidthOptions = [
'7.8 kHz',
'10.4 kHz',
'15.6 kHz',
'20.8 kHz',
'31.25 kHz',
'41.7 kHz',
'62.5 kHz',
'125 kHz',
'250 kHz',
'500 kHz',
];
String _selectedBandwidth = '62.5 kHz';
bool _isSpectrumScanRunning = false;
bool _rangeInitialized = false;
String? _lastRangeSourceKey;
late double _scanRangeMinMhz;
late double _scanRangeMaxMhz;
late RangeValues _scanRangeValues;
List<SpectrumScanCandidate> _scanCandidates = const [];
int? _selectedScanFrequencyKhz;
int _completedScanSectors = 0;
int _totalScanSectors = 0;
List<SpectrumScanCandidate> get _recommendedScanCandidates =>
_scanCandidates.take(8).toList();
@override
void initState() {
super.initState();
final deviceInfo = _deviceInfo;
if (deviceInfo.radioBw != null &&
deviceInfo.radioBw! >= 0 &&
deviceInfo.radioBw! < _bandwidthOptions.length) {
_selectedBandwidth = _bandwidthOptions[deviceInfo.radioBw!];
}
_syncRangeFromDevice(deviceInfo);
_selectedScanFrequencyKhz = deviceInfo.radioFreq;
}
DeviceInfo get _deviceInfo => context.read<ConnectionProvider>().deviceInfo;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_syncRangeFromDevice(context.watch<ConnectionProvider>().deviceInfo);
}
void _syncRangeFromDevice(DeviceInfo deviceInfo) {
final minKhz = deviceInfo.spectrumScanMinKhz;
final maxKhz = deviceInfo.spectrumScanMaxKhz;
late double normalizedMinMhz;
late double normalizedMaxMhz;
late String sourceKey;
if (minKhz != null && maxKhz != null && maxKhz > minKhz) {
final normalized = _normalizeScanRange(
minKhz / 1000.0,
maxKhz / 1000.0,
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
);
normalizedMinMhz = normalized.$1;
normalizedMaxMhz = normalized.$2;
sourceKey = 'fw:$minKhz:$maxKhz';
} else {
final normalized = _normalizeScanRange(
null,
null,
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
);
normalizedMinMhz = normalized.$1;
normalizedMaxMhz = normalized.$2;
sourceKey =
'fallback:${deviceInfo.radioFreq != null ? deviceInfo.radioFreq! ~/ 1000 : 869525}';
}
if (_rangeInitialized && _lastRangeSourceKey == sourceKey) {
return;
}
_scanRangeMinMhz = normalizedMinMhz;
_scanRangeMaxMhz = normalizedMaxMhz;
if (_scanRangeMaxMhz <= _scanRangeMinMhz) {
_scanRangeMaxMhz = _scanRangeMinMhz + 0.5;
}
_scanRangeValues = RangeValues(_scanRangeMinMhz, _scanRangeMaxMhz);
_rangeInitialized = true;
_lastRangeSourceKey = sourceKey;
}
(double, double) _normalizeScanRange(
double? minMhz,
double? maxMhz,
double? centerMhz,
) {
const hardMinMhz = 800.0;
const hardMaxMhz = 950.0;
final center = centerMhz ?? 869.525;
if (minMhz != null && maxMhz != null) {
final clampedMin = minMhz.clamp(hardMinMhz, hardMaxMhz);
final clampedMax = maxMhz.clamp(hardMinMhz, hardMaxMhz);
if (clampedMax > clampedMin) {
return (clampedMin, clampedMax);
}
}
if (center >= 900.0 && center <= 930.0) {
return (902.0, 928.0);
}
return (863.0, 870.0);
}
double _bandwidthToKhz(String bw) {
switch (bw) {
case '7.8 kHz':
return 7.8;
case '10.4 kHz':
return 10.4;
case '15.6 kHz':
return 15.6;
case '20.8 kHz':
return 20.8;
case '31.25 kHz':
return 31.25;
case '41.7 kHz':
return 41.7;
case '62.5 kHz':
return 62.5;
case '125 kHz':
return 125.0;
case '250 kHz':
return 250.0;
case '500 kHz':
return 500.0;
default:
return 62.5;
}
}
String _currentParamProfile(DeviceInfo deviceInfo) {
final sf = deviceInfo.radioSf ?? 8;
final cr = deviceInfo.radioCr ?? 8;
return 'BW $_selectedBandwidth | SF$sf | CR 4/$cr';
}
String _recommendationTitle(int index) {
switch (index) {
case 0:
return 'Best candidate';
case 1:
return 'Alternate';
case 2:
return 'Fallback';
default:
return 'Candidate ${index + 1}';
}
}
List<int> _possibleBracketFrequenciesKhz() {
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
final halfBandwidthKhz = bandwidthKhz / 2.0;
final startKhz = (_scanRangeValues.start * 1000).round();
final stopKhz = (_scanRangeValues.end * 1000).round();
final firstCenterKhz = (startKhz + halfBandwidthKhz).round();
final lastCenterKhz = (stopKhz - halfBandwidthKhz).round();
if (lastCenterKhz < firstCenterKhz) {
return const [];
}
final stepKhz = bandwidthKhz >= 125.0 ? bandwidthKhz.round() : 25;
final centers = <int>[];
for (
var centerKhz = firstCenterKhz;
centerKhz <= lastCenterKhz && centers.length < 8;
centerKhz += stepKhz
) {
centers.add(centerKhz);
}
if (centers.isEmpty || centers.last != lastCenterKhz) {
centers.add(lastCenterKhz);
}
return centers.toSet().toList()..sort();
}
void _resetDerivedScanResults() {
_scanCandidates = const [];
_selectedScanFrequencyKhz = null;
}
List<(int startKhz, int stopKhz)> _buildScanSectors(double bandwidthKhz) {
final startKhz = (_scanRangeValues.start * 1000).round();
final stopKhz = (_scanRangeValues.end * 1000).round();
final sectorWidthKhz = (bandwidthKhz * 24).round().clamp(250, 1200);
final overlapKhz = bandwidthKhz.round().clamp(8, 500);
final sectors = <(int startKhz, int stopKhz)>[];
var sectorStartKhz = startKhz;
while (sectorStartKhz < stopKhz) {
final sectorStopKhz = (sectorStartKhz + sectorWidthKhz).clamp(
sectorStartKhz + overlapKhz,
stopKhz,
);
sectors.add((sectorStartKhz, sectorStopKhz));
if (sectorStopKhz >= stopKhz) {
break;
}
sectorStartKhz = sectorStopKhz - overlapKhz;
}
return sectors;
}
List<SpectrumScanCandidate> _mergeSectorCandidates(
Iterable<SpectrumScanCandidate> candidates,
) {
final byFrequency = <int, SpectrumScanCandidate>{};
for (final candidate in candidates) {
final existing = byFrequency[candidate.centerFrequencyKhz];
if (existing == null ||
candidate.occupancyPercent < existing.occupancyPercent ||
(candidate.occupancyPercent == existing.occupancyPercent &&
candidate.peakRssiDbm < existing.peakRssiDbm) ||
(candidate.occupancyPercent == existing.occupancyPercent &&
candidate.peakRssiDbm == existing.peakRssiDbm &&
candidate.avgRssiDbm < existing.avgRssiDbm)) {
byFrequency[candidate.centerFrequencyKhz] = candidate;
}
}
final merged = byFrequency.values.toList()
..sort((a, b) {
final occupancyCompare = a.occupancyPercent.compareTo(
b.occupancyPercent,
);
if (occupancyCompare != 0) return occupancyCompare;
final peakCompare = a.peakRssiDbm.compareTo(b.peakRssiDbm);
if (peakCompare != 0) return peakCompare;
return a.avgRssiDbm.compareTo(b.avgRssiDbm);
});
return merged;
}
Future<void> _runSpectrumScan() async {
final connectionProvider = context.read<ConnectionProvider>();
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
final sectors = _buildScanSectors(bandwidthKhz);
final sectorCandidates = <SpectrumScanCandidate>[];
setState(() {
_isSpectrumScanRunning = true;
_resetDerivedScanResults();
_completedScanSectors = 0;
_totalScanSectors = sectors.length;
});
try {
for (var i = 0; i < sectors.length; i++) {
final sector = sectors[i];
final result = await connectionProvider.scanSpectrum(
startFrequencyKhz: sector.$1,
stopFrequencyKhz: sector.$2,
bandwidthKhz: bandwidthKhz.round(),
stepKhz: (bandwidthKhz / 2).round().clamp(1, 1000),
dwellMs: 160,
thresholdDb: 8,
);
if (result != null) {
sectorCandidates.addAll(result.candidates);
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
if (mounted) {
setState(() {
_scanCandidates = mergedCandidates;
if (_selectedScanFrequencyKhz == null &&
mergedCandidates.isNotEmpty) {
_selectedScanFrequencyKhz =
mergedCandidates.first.centerFrequencyKhz;
}
_completedScanSectors = i + 1;
});
}
} else if (mounted) {
setState(() {
_completedScanSectors = i + 1;
});
}
}
} finally {
if (mounted) {
setState(() {
_isSpectrumScanRunning = false;
if (_completedScanSectors == 0) {
_totalScanSectors = sectors.length;
}
});
}
}
if (!mounted) return;
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
if (mergedCandidates.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Spectrum scan returned no candidate frequencies'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() {
_scanCandidates = mergedCandidates;
_selectedScanFrequencyKhz = mergedCandidates.first.centerFrequencyKhz;
});
}
Future<void> _applySelectedScanFrequency() async {
if (_selectedScanFrequencyKhz == null) return;
final connectionProvider = context.read<ConnectionProvider>();
final deviceInfo = connectionProvider.deviceInfo;
await connectionProvider.setRadioParams(
frequency: _selectedScanFrequencyKhz!,
bandwidth: _bandwidthOptions.indexOf(_selectedBandwidth),
spreadingFactor: deviceInfo.radioSf ?? 8,
codingRate: deviceInfo.radioCr ?? 8,
repeat: deviceInfo.clientRepeat,
);
await connectionProvider.refreshDeviceInfo();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Applied ${(_selectedScanFrequencyKhz! / 1000.0).toStringAsFixed(3)} MHz',
),
backgroundColor: Colors.green,
),
);
}
int? _currentPreviewFrequencyKhz(DeviceInfo deviceInfo) {
if (_selectedScanFrequencyKhz != null) {
return _selectedScanFrequencyKhz;
}
return deviceInfo.radioFreq;
}
@override
Widget build(BuildContext context) {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
final theme = Theme.of(context);
final possibleBracketFrequencies = _possibleBracketFrequenciesKhz();
final recommendedScanCandidates = _recommendedScanCandidates;
final recommendationFrequencies = recommendedScanCandidates.isNotEmpty
? recommendedScanCandidates
.map((candidate) => candidate.centerFrequencyKhz)
.toList()
: possibleBracketFrequencies;
return Scaffold(
appBar: AppBar(title: const Text('Spectrum Scan')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: _selectedBandwidth,
decoration: const InputDecoration(
labelText: 'Bandwidth',
border: OutlineInputBorder(),
helperText:
'Scan and apply frequencies for this bandwidth',
),
items: _bandwidthOptions.map((value) {
return DropdownMenuItem<String>(
value: value,
child: Text(value),
);
}).toList(),
onChanged: (value) {
if (value == null) return;
setState(() {
_selectedBandwidth = value;
_resetDerivedScanResults();
});
},
),
const SizedBox(height: 16),
Text(
deviceInfo.spectrumScanMinKhz != null &&
deviceInfo.spectrumScanMaxKhz != null
? 'Firmware scan range: ${_scanRangeMinMhz.toStringAsFixed(3)}-${_scanRangeMaxMhz.toStringAsFixed(3)} MHz'
: 'Fallback scan range selected from current band. MeshCore commonly uses EU868 or US915.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (_isSpectrumScanRunning) ...[
const SizedBox(height: 8),
Text(
'Scanning sector ${_completedScanSectors + 1} of $_totalScanSectors. Results update as each sector completes.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: 12),
SpectrumScanPanel(
theme: theme,
scanSupported: deviceInfo.supportsSpectrumScan == true,
isRunning: _isSpectrumScanRunning,
rangeMinMhz: _scanRangeMinMhz,
rangeMaxMhz: _scanRangeMaxMhz,
rangeValues: _scanRangeValues,
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
selectedFrequencyKhz: _currentPreviewFrequencyKhz(
deviceInfo,
),
graphCandidates: _scanCandidates,
selectableCandidates: recommendedScanCandidates,
onRangeChanged: (values) {
setState(() {
_scanRangeValues = values;
_resetDerivedScanResults();
});
},
onCandidateChanged: (value) {
setState(() {
_selectedScanFrequencyKhz = value;
});
},
onRunScan: _runSpectrumScan,
onApplySelected: _applySelectedScanFrequency,
),
if (recommendationFrequencies.isNotEmpty) ...[
const SizedBox(height: 18),
Text(
_scanCandidates.isNotEmpty
? 'Recommended profiles'
: 'Possible brackets',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
_scanCandidates.isNotEmpty
? 'Suggested frequencies from the latest scan with the radio parameters to keep alongside them.'
: 'Usable frequency brackets derived from the selected span and bandwidth, even without live scan data.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 12),
for (
var i = 0;
i < recommendationFrequencies.length;
i++
) ...[
_RecommendationTile(
title: _recommendationTitle(i),
frequencyKhz: recommendationFrequencies[i],
candidate: recommendedScanCandidates.isNotEmpty
? recommendedScanCandidates[i]
: null,
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
paramsLabel: _currentParamProfile(deviceInfo),
isSelected:
_selectedScanFrequencyKhz ==
recommendationFrequencies[i],
onSelect: () {
setState(() {
_selectedScanFrequencyKhz =
recommendationFrequencies[i];
});
},
),
if (i != recommendationFrequencies.length - 1)
const SizedBox(height: 10),
],
],
],
),
),
),
],
),
);
}
}
class _RecommendationTile extends StatelessWidget {
final String title;
final int frequencyKhz;
final SpectrumScanCandidate? candidate;
final double bandwidthKhz;
final String paramsLabel;
final bool isSelected;
final VoidCallback onSelect;
const _RecommendationTile({
required this.title,
required this.frequencyKhz,
required this.candidate,
required this.bandwidthKhz,
required this.paramsLabel,
required this.isSelected,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
return InkWell(
onTap: onSelect,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isSelected
? scheme.primaryContainer.withValues(alpha: 0.55)
: scheme.surfaceContainerHighest.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected ? scheme.primary : scheme.outlineVariant,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (isSelected)
Icon(Icons.check_circle, color: scheme.primary, size: 18),
],
),
const SizedBox(height: 8),
Text(
'${(frequencyKhz / 1000.0).toStringAsFixed(3)} MHz',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(paramsLabel, style: theme.textTheme.bodyMedium),
const SizedBox(height: 6),
Text(
candidate != null
? 'Occupancy ${candidate!.occupancyPercent}% | Avg ${candidate!.avgRssiDbm} dBm | Peak ${candidate!.peakRssiDbm} dBm'
: 'Bracket ${(frequencyKhz / 1000.0 - bandwidthKhz / 2000.0).toStringAsFixed(3)}-${(frequencyKhz / 1000.0 + bandwidthKhz / 2000.0).toStringAsFixed(3)} MHz',
style: theme.textTheme.bodySmall?.copyWith(
color: scheme.onSurfaceVariant,
),
),
],
),
),
);
}
}

View File

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

View File

@@ -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!.clamp(10.0, 1000.0);
}
if (section.fastLocationActiveCadenceSeconds != null) {
locationTracking.fastLocationActiveCadenceSeconds =
section.fastLocationActiveCadenceSeconds!.clamp(10, 31);
}
await locationTracking.saveSettings();
await appProvider.reloadProfileScopedSettings();
}
}

View File

@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:io';
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
@@ -14,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;
@@ -58,18 +65,27 @@ class BackgroundLocationService {
return false;
}
if (Platform.isIOS && permission != LocationPermission.always) {
permission = await Geolocator.requestPermission();
if (permission != LocationPermission.always) {
debugPrint(
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
);
return false;
}
}
// Save settings
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, true);
await prefs.setDouble(_prefKeyDistance, distanceThreshold);
await prefs.setBool(_scopedKey(_prefKeyEnabled), true);
await prefs.setDouble(_scopedKey(_prefKeyDistance), distanceThreshold);
// Start listening to position updates
Position? lastPosition;
try {
_positionSubscription =
Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
distanceFilter: distanceThreshold.toInt(),
),
).listen((Position position) async {
@@ -100,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) {
@@ -151,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');
}
@@ -159,16 +181,34 @@ 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);
}
}
LocationSettings _buildLocationSettings({int distanceFilter = 10}) {
if (Platform.isIOS) {
return AppleSettings(
accuracy: LocationAccuracy.best,
activityType: ActivityType.fitness,
allowBackgroundLocationUpdates: true,
distanceFilter: distanceFilter,
pauseLocationUpdatesAutomatically: false,
showBackgroundLocationIndicator: true,
);
}
return LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceFilter,
);
}
}

View File

@@ -0,0 +1,178 @@
import '../models/contact.dart';
enum BTHomeMetMeasurement {
temperature(1, 'Temperature', '°C'),
humidity(2, 'Humidity', '%'),
windSpeed(3, 'Wind speed', 'm/s'),
gust(4, 'Wind gust', 'm/s'),
rain(5, 'Rain', 'mm');
const BTHomeMetMeasurement(this.id, this.label, this.unit);
final int id;
final String label;
final String unit;
static BTHomeMetMeasurement? fromId(int id) {
for (final value in BTHomeMetMeasurement.values) {
if (value.id == id) {
return value;
}
}
return null;
}
}
class BTHomeMetHistoryPage {
const BTHomeMetHistoryPage({
required this.measurement,
required this.page,
required this.values,
});
final BTHomeMetMeasurement measurement;
final int page;
final List<double> values;
double? get latest => values.isEmpty ? null : values.last;
double? get minimum => values.isEmpty
? null
: values.reduce((left, right) => left < right ? left : right);
double? get maximum => values.isEmpty
? null
: values.reduce((left, right) => left > right ? left : right);
}
class BTHomeMetHistoryFormatException implements Exception {
const BTHomeMetHistoryFormatException(this.message);
final String message;
@override
String toString() => message;
}
class BTHomeMetHistoryParser {
static BTHomeMetHistoryPage parse(String text) {
final parts = text
.trim()
.split(',')
.map((part) => part.trim())
.toList(growable: false);
if (parts.length < 3) {
throw const BTHomeMetHistoryFormatException(
'MET history response is too short.',
);
}
final measurementId = int.tryParse(parts[0]);
final page = int.tryParse(parts[1]);
final count = int.tryParse(parts[2]);
if (measurementId == null || page == null || count == null) {
throw const BTHomeMetHistoryFormatException(
'MET history header is invalid.',
);
}
final measurement = BTHomeMetMeasurement.fromId(measurementId);
if (measurement == null) {
throw BTHomeMetHistoryFormatException(
'Unsupported MET history measurement id: $measurementId',
);
}
if (count < 0) {
throw const BTHomeMetHistoryFormatException(
'MET history sample count is invalid.',
);
}
if (parts.length != count + 3) {
throw BTHomeMetHistoryFormatException(
'MET history sample count mismatch: expected $count values, got ${parts.length - 3}.',
);
}
final values = <double>[];
for (final part in parts.skip(3)) {
final value = double.tryParse(part);
if (value == null) {
throw BTHomeMetHistoryFormatException(
'Invalid MET history sample value: $part',
);
}
values.add(value);
}
return BTHomeMetHistoryPage(
measurement: measurement,
page: page,
values: List<double>.unmodifiable(values),
);
}
}
bool _isTruthyCapabilityValue(Object? value) {
if (value is num) {
return value > 0;
}
if (value is bool) {
return value;
}
return false;
}
bool _hasBTHomeMetCapability(Contact? contact) {
final extraSensorData = contact?.telemetry?.extraSensorData;
if (extraSensorData == null) {
return false;
}
// MET history is only enabled when the node advertises an explicit
// capability marker on channel 1. Accept a small set of channel-1 marker
// keys so the app remains tolerant while firmware-side encoding settles.
const capabilityKeys = <String>[
'met_capability',
'met_capability_1',
'generic_sensor_1',
'light_level_1',
];
for (final key in capabilityKeys) {
if (_isTruthyCapabilityValue(extraSensorData[key])) {
return true;
}
}
return false;
}
List<BTHomeMetMeasurement> bTHomeMetMeasurementsForContact(Contact? contact) {
final telemetry = contact?.telemetry;
if (telemetry == null || !_hasBTHomeMetCapability(contact)) {
return const <BTHomeMetMeasurement>[];
}
final measurements = <BTHomeMetMeasurement>[
if (telemetry.temperature != null) BTHomeMetMeasurement.temperature,
if (telemetry.humidity != null) BTHomeMetMeasurement.humidity,
];
final extraSensorData = telemetry.extraSensorData;
if (extraSensorData != null) {
if (extraSensorData.keys.any(
(key) => key.startsWith('speed_') || key.startsWith('signed_speed_'),
)) {
measurements.add(BTHomeMetMeasurement.windSpeed);
}
if (extraSensorData.keys.any((key) => key.startsWith('gust_'))) {
measurements.add(BTHomeMetMeasurement.gust);
}
if (extraSensorData.keys.any((key) => key.startsWith('rain_'))) {
measurements.add(BTHomeMetMeasurement.rain);
}
}
return List<BTHomeMetMeasurement>.unmodifiable(measurements);
}
bool supportsBTHomeMetHistory(Contact? contact) =>
bTHomeMetMeasurementsForContact(contact).isNotEmpty;

View File

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

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...');
@@ -18,14 +88,18 @@ class CayenneLppParser {
LatLng? gpsLocation;
double? batteryPercentage;
double? batteryMilliVolts;
double? deferredBatteryMilliVolts;
double? deferredBatteryPercentage;
double? temperature;
double? humidity;
double? pressure;
final extraSensorData = <String, dynamic>{};
bool sawNonSelfChannel = false;
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}',
@@ -41,6 +115,9 @@ class CayenneLppParser {
final channel = reader.readByte();
debugPrint(' Channel: $channel');
if (channel != _selfTelemetryChannel) {
sawNonSelfChannel = true;
}
final type = reader.readByte();
debugPrint(
@@ -65,14 +142,20 @@ 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 (_isDedicatedBatteryChannel(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 if (_isDeferredBatteryChannel(channel)) {
deferredBatteryMilliVolts = value * 1000;
deferredBatteryPercentage = _calculateBatteryPercentage(value);
extraSensorData['analog_input_$channel'] = value;
} else {
extraSensorData['analog_input_$channel'] = value;
}
break;
@@ -85,7 +168,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 +181,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 +227,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 +247,21 @@ 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 (_isDedicatedBatteryChannel(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 if (_isDeferredBatteryChannel(channel)) {
deferredBatteryMilliVolts = value * 1000;
deferredBatteryPercentage = _calculateBatteryPercentage(value);
extraSensorData['voltage_$channel'] = value;
} else {
extraSensorData['voltage_$channel'] = value;
}
break;
case MeshCoreConstants.lppGyrometer:
@@ -194,15 +314,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 (_isDedicatedBatteryChannel(channel) || _isDeferredBatteryChannel(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) {
@@ -212,6 +645,21 @@ class CayenneLppParser {
}
}
if (batteryMilliVolts == null &&
batteryPercentage == null &&
deferredBatteryMilliVolts != null &&
deferredBatteryPercentage != null &&
!sawNonSelfChannel) {
batteryMilliVolts = deferredBatteryMilliVolts;
batteryPercentage = deferredBatteryPercentage;
extraSensorData[_sourceChannelKey('battery')] = _selfTelemetryChannel;
extraSensorData[_sourceChannelKey('voltage')] = _selfTelemetryChannel;
debugPrint(
' Promoted self-channel voltage to battery: '
'${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
}
debugPrint(' Parsed $fieldCount fields');
debugPrint(' ✅ [CayenneLPP] Parsing complete');
debugPrint(
@@ -258,6 +706,165 @@ class CayenneLppParser {
return ((voltage - 3.0) / 1.2) * 100.0;
}
static bool _isDedicatedBatteryChannel(int channel) => channel == 0;
static bool _isDeferredBatteryChannel(int channel) =>
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

@@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
class CompassSupport {
const CompassSupport._();
static bool get isAvailable =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
}

View File

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

View File

@@ -2,16 +2,25 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../models/contact_group.dart';
import 'profiles_feature_service.dart';
import '../utils/key_comparison.dart';
import 'package:latlong2/latlong.dart';
/// Service for persisting contacts to local storage
class ContactStorageService {
static const String _contactsKey = 'stored_contacts';
static const String _contactGroupsKey = 'stored_contact_groups';
static const String _pendingAdvertsKey = 'stored_pending_adverts';
static const int _maxStoredContacts = 500; // Store up to 500 contacts
static const int _maxStoredPendingAdverts = 500;
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();
@@ -26,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',
@@ -38,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');
@@ -80,21 +97,158 @@ 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');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
Future<void> saveContactGroups(
List<SavedContactGroup> groups, {
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_contactsKey);
final jsonString = jsonEncode(
groups.map((group) => _contactGroupToJson(group)).toList(),
);
await prefs.setString(
_key(_contactGroupsKey, namespace: namespace),
jsonString,
);
debugPrint(
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
}
}
Future<List<SavedContactGroup>> loadContactGroups({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(
_key(_contactGroupsKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return [];
}
final jsonList = jsonDecode(jsonString) as List<dynamic>;
return jsonList
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
.whereType<SavedContactGroup>()
.toList();
} catch (e) {
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
return [];
}
}
Future<void> clearContactGroups({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
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};
@@ -123,6 +277,7 @@ class ContactStorageService {
'outPathLen': contact.outPathLen,
'outPath': base64Encode(contact.outPath),
'advName': contact.advName,
'nameOverride': contact.nameOverride,
'lastAdvert': contact.lastAdvert,
'advLat': contact.advLat,
'advLon': contact.advLon,
@@ -130,6 +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(),
};
}
@@ -145,6 +309,7 @@ class ContactStorageService {
outPathLen: json['outPathLen'] as int,
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
advName: json['advName'] as String,
nameOverride: json['nameOverride'] as String?,
lastAdvert: json['lastAdvert'] as int,
advLat: json['advLat'] as int,
advLon: json['advLon'] as int,
@@ -152,6 +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');
@@ -203,4 +369,56 @@ class ContactStorageService {
return null;
}
}
List<AdvertLocation> _advertHistoryFromJson(dynamic json) {
if (json is! List) return [];
final result = <AdvertLocation>[];
for (final item in json) {
if (item is! Map<String, dynamic>) continue;
final lat = item['lat'];
final lon = item['lon'];
final tsMillis = item['tsMillis'];
if (lat is! num || lon is! num || tsMillis is! int) continue;
result.add(
AdvertLocation(
location: LatLng(lat.toDouble(), lon.toDouble()),
timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis),
),
);
}
return result;
}
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
return {
'id': group.id,
'sectionKey': group.sectionKey,
'label': group.label,
'query': group.query,
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
'matchPrefixes': group.matchPrefixes,
'isAutoGroup': group.isAutoGroup,
};
}
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
try {
return SavedContactGroup(
id: json['id'] as String,
sectionKey: json['sectionKey'] as String,
label: json['label'] as String,
query: json['query'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(
json['createdAtMillis'] as int,
),
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
?.map((value) => value as String)
.toList(),
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
);
} catch (e) {
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
return null;
}
}
}

View File

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

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

View File

@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:io';
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
///
@@ -18,6 +20,14 @@ import 'package:meshcore_client/meshcore_client.dart';
/// - MeshCore mesh network integration
/// - Real-time position updates via callbacks
class LocationTrackingService {
static const double _defaultFastLocationMovementThresholdMeters = 10.0;
static const double _minFastLocationMovementThresholdMeters = 10.0;
static const int _defaultFastLocationActiveCadenceSeconds = 10;
static const int _minFastLocationActiveCadenceSeconds = 10;
static const int _maxFastLocationActiveCadenceSeconds = 31;
static const Duration _fastLocationMinimumUpdateInterval = Duration(
seconds: 31,
);
// ============================================================================
// Singleton Pattern
// ============================================================================
@@ -47,6 +57,11 @@ class LocationTrackingService {
'fast_location_movement_threshold_meters';
static const String _prefKeyFastActiveCadence =
'fast_location_active_cadence_seconds';
static const String _prefKeyFastChannelIdx = 'fast_location_channel_idx';
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
}
// ============================================================================
// Configuration Properties
@@ -68,10 +83,15 @@ class LocationTrackingService {
bool fastLocationUpdatesEnabled = false;
/// Distance threshold for fast GPS updates
double fastLocationMovementThresholdMeters = 10.0;
double fastLocationMovementThresholdMeters =
_defaultFastLocationMovementThresholdMeters;
/// Cadence for active-use fast GPS updates
int fastLocationActiveCadenceSeconds = 10;
int fastLocationActiveCadenceSeconds =
_defaultFastLocationActiveCadenceSeconds;
/// Target channel index for fast GPS updates; null means disabled/unset.
int? fastLocationChannelIdx;
// ============================================================================
// State Properties
@@ -148,6 +168,10 @@ class LocationTrackingService {
/// Check if location permissions are granted
Future<bool> checkPermissions() async {
final permission = await Geolocator.checkPermission();
if (Platform.isIOS) {
return permission == LocationPermission.always;
}
return permission == LocationPermission.always ||
permission == LocationPermission.whileInUse;
}
@@ -181,6 +205,16 @@ class LocationTrackingService {
return false;
}
if (Platform.isIOS && permission != LocationPermission.always) {
permission = await Geolocator.requestPermission();
if (permission != LocationPermission.always) {
onError?.call(
'Background tracking on iOS requires "Always" location access. Enable it in Settings.',
);
return false;
}
}
debugPrint('✅ [LocationTracking] Location permissions granted');
return true;
}
@@ -209,9 +243,9 @@ class LocationTrackingService {
}
final position = await Geolocator.getCurrentPosition(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
timeLimit: timeLimit,
distanceFilter: 0,
),
);
@@ -255,13 +289,35 @@ class LocationTrackingService {
/// [distanceFilter] - Minimum distance in meters between position updates
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
return Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.best,
locationSettings: _buildLocationSettings(
distanceFilter: distanceFilter.toInt(),
),
);
}
LocationSettings _buildLocationSettings({
int distanceFilter = 10,
Duration? timeLimit,
}) {
if (Platform.isIOS) {
return AppleSettings(
accuracy: LocationAccuracy.best,
activityType: ActivityType.fitness,
allowBackgroundLocationUpdates: true,
distanceFilter: distanceFilter,
pauseLocationUpdatesAutomatically: false,
showBackgroundLocationIndicator: true,
timeLimit: timeLimit,
);
}
return LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceFilter,
timeLimit: timeLimit,
);
}
// ============================================================================
// Tracking Control
// ============================================================================
@@ -362,7 +418,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');
}
@@ -440,8 +496,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');
@@ -476,18 +532,30 @@ class LocationTrackingService {
}
Future<void> updateFastLocationMovementThreshold(double meters) async {
fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0);
fastLocationMovementThresholdMeters = meters.clamp(
_minFastLocationMovementThresholdMeters,
1000.0,
);
await saveSettings();
}
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
fastLocationActiveCadenceSeconds = seconds.clamp(5, 60);
fastLocationActiveCadenceSeconds = seconds.clamp(
_minFastLocationActiveCadenceSeconds,
_maxFastLocationActiveCadenceSeconds,
);
await saveSettings();
_refreshFastLocationTimer();
}
Future<void> updateFastLocationChannelIdx(int? channelIdx) async {
fastLocationChannelIdx = channelIdx;
await saveSettings();
_refreshFastLocationTimer();
}
void _evaluateFastLocationMovement(Position position) {
if (!fastLocationUpdatesEnabled) return;
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
final previous = _lastFastLocationSentPosition;
if (previous == null) {
_emitFastLocationUpdate(position, reason: 'initial');
@@ -510,6 +578,7 @@ class LocationTrackingService {
_fastLocationTimer = null;
if (!isTracking ||
!fastLocationUpdatesEnabled ||
fastLocationChannelIdx == null ||
!_isFastLocationActiveUse) {
return;
}
@@ -525,11 +594,16 @@ class LocationTrackingService {
}
void _emitFastLocationUpdate(Position position, {required String reason}) {
if (!fastLocationUpdatesEnabled) return;
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
final now = DateTime.now();
final previous = _lastFastLocationSentPosition;
final previousTime = _lastFastLocationSentAt;
if (previousTime != null &&
now.difference(previousTime) < _fastLocationMinimumUpdateInterval) {
return;
}
if (previous != null && previousTime != null) {
final distance = Geolocator.distanceBetween(
previous.latitude,
@@ -609,17 +683,27 @@ 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)
.clamp(1.0, 1000.0);
(prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ??
_defaultFastLocationMovementThresholdMeters)
.clamp(_minFastLocationMovementThresholdMeters, 1000.0);
fastLocationActiveCadenceSeconds =
(prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60);
(prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ??
_defaultFastLocationActiveCadenceSeconds)
.clamp(
_minFastLocationActiveCadenceSeconds,
_maxFastLocationActiveCadenceSeconds,
);
fastLocationChannelIdx = prefs.getInt(_scopedKey(_prefKeyFastChannelIdx));
debugPrint('✅ [LocationTracking] Settings loaded');
debugPrint(' Min distance: ${minDistanceMeters}m');
@@ -631,29 +715,42 @@ class LocationTrackingService {
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
);
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
debugPrint(' Fast channel idx: ${fastLocationChannelIdx ?? "unset"}');
}
/// Save settings to SharedPreferences
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,
);
final channelKey = _scopedKey(_prefKeyFastChannelIdx);
if (fastLocationChannelIdx == null) {
await prefs.remove(channelKey);
} else {
await prefs.setInt(channelKey, fastLocationChannelIdx!);
}
debugPrint('✅ [LocationTracking] Settings saved');
}

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

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

View File

@@ -6,11 +6,13 @@ 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
class MessageStorageService {
static const String _messagesKey = 'stored_messages';
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
static const String _messageContactLocationsKey =
'stored_message_contact_locations';
static const String _messageReceptionDetailsKey =
@@ -19,8 +21,14 @@ class MessageStorageService {
'stored_message_transfer_details';
static const String _messageRouteMetadataKey =
'stored_message_route_metadata';
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
static const String _legacyPathBytesKey = 'storedPathBytes';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
String _key(String baseKey, {String? namespace}) {
return ProfileStorageScope.scopedKey(baseKey, namespace: namespace);
}
/// Save messages to persistent storage
Future<void> saveMessages(
List<Message> messages, {
@@ -28,12 +36,21 @@ 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();
// Convert messages to JSON
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
// Convert messages to JSON and embed path bytes as a fallback so they
// survive restore even if the sidecar reception-details entry is absent.
final jsonList = messages
.map(
(msg) => _messageToJson(
msg,
receptionDetails: messageReceptionDetails[msg.id],
),
)
.toList();
// Limit to max stored messages (keep most recent)
final limitedList = jsonList.length > _maxStoredMessages
@@ -41,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();
@@ -70,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),
);
@@ -94,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 {};
}
@@ -124,29 +147,48 @@ 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);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final jsonString = prefs.getString(
_key(_messageReceptionDetailsKey, namespace: namespace),
);
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
if (jsonString != null && jsonString.isNotEmpty) {
final decoded = jsonDecode(jsonString);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
}
}
}
}
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails(
namespace: namespace,
);
embeddedReceptionDetails.forEach((messageId, snapshot) {
result.putIfAbsent(messageId, () => snapshot);
});
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages(
namespace: namespace,
);
fallbackPathBytes.forEach((messageId, pathBytes) {
result.putIfAbsent(
messageId,
() => MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
pathBytes: pathBytes,
),
);
});
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
@@ -154,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 {};
}
@@ -184,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 {};
}
@@ -211,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');
@@ -239,25 +290,67 @@ 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(_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');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
Future<Set<String>> loadRemovedSarMarkerIds({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messagesKey);
final ids =
prefs.getStringList(
_key(_removedSarMarkerIdsKey, namespace: namespace),
) ??
const [];
return ids.toSet();
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
return const <String>{};
}
}
Future<void> saveRemovedSarMarkerIds(
Set<String> ids, {
String? namespace,
}) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_key(_removedSarMarkerIdsKey, namespace: namespace),
ids.toList()..sort(),
);
debugPrint(
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
);
} catch (e) {
debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e');
}
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(
_key(_messagesKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
@@ -278,7 +371,12 @@ class MessageStorageService {
}
/// Convert Message to JSON
Map<String, dynamic> _messageToJson(Message message) {
Map<String, dynamic> _messageToJson(
Message message, {
MessageReceptionDetails? receptionDetails,
}) {
final persistedPathBytes =
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
return {
'id': message.id,
'messageType': message.messageType.name,
@@ -287,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,
@@ -338,9 +440,74 @@ class MessageStorageService {
},
)
.toList(),
if (receptionDetails != null)
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
if (receptionDetails?.pathBytes case final pathBytes?)
_legacyPathBytesKey: List<int>.from(pathBytes),
};
}
Future<Map<String, MessageReceptionDetails>> _loadEmbeddedReceptionDetails({
String? namespace,
}) async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(
_key(_messagesKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, MessageReceptionDetails>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final embedded = entry[_embeddedReceptionDetailsKey];
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
final snapshot = MessageReceptionDetails.fromJson(embedded);
if (snapshot == null) continue;
result[messageId] = snapshot;
}
return result;
}
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages({
String? namespace,
}) async {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(
_key(_messagesKey, namespace: namespace),
);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! List) {
return const {};
}
final result = <String, List<int>>{};
for (final entry in decoded) {
if (entry is! Map<String, dynamic>) continue;
final messageId = entry['id'];
final pathBytes = entry[_legacyPathBytesKey];
if (messageId is! String || pathBytes is! List) continue;
final normalized = pathBytes
.whereType<num>()
.map((b) => b.toInt())
.toList();
if (normalized.isEmpty) continue;
result[messageId] = normalized;
}
return result;
}
/// Convert JSON to Message
Message? _messageFromJson(Map<String, dynamic> json) {
try {
@@ -357,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?,
@@ -454,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,
);
}
}

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