Compare commits

..

86 Commits

Author SHA1 Message Date
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
Janez T
d113f7a881 Match sensors bg and hide counts 2026-03-08 21:03:44 +01:00
Janez T
2ab8681ac4 Add multilingual localization 2026-03-08 20:50:53 +01:00
Janez T
de5fb1e164 Translate missing localization 2026-03-08 20:46:09 +01:00
Janez T
dae7e67a8a Translate missing strings 2026-03-08 20:42:21 +01:00
Janez T
d51924021f Update German localization 2026-03-08 20:38:41 +01:00
Janez T
0f91292fd7 Update messaging options and UI 2026-03-08 20:34:38 +01:00
Janez T
b471ab0990 Add message font scale setting 2026-03-08 20:29:55 +01:00
Janez T
771cddb80d Display unread counts per channel 2026-03-08 20:22:10 +01:00
Janez T
bfd15065f0 Add message filters and counts 2026-03-08 20:15:48 +01:00
Janez T
f72db76538 Add reply shortcut for messages 2026-03-08 20:10:51 +01:00
Janez T
a96f96ca4c Add nearestrelay fallback settings 2026-03-08 20:05:24 +01:00
Janez T
9f62055eef Enable route hash settings 2026-03-08 17:00:54 +01:00
Janez T
6b2f18130c Add nearest relay fallback toggle 2026-03-08 16:56:57 +01:00
Janez T
922b40b755 Implement meshcore route rotation 2026-03-08 16:46:46 +01:00
Janez T
17d7c43745 Implement meshcore-open route reload 2026-03-08 14:26:05 +01:00
Janez T
e026c1dbbd Fix invalid simulator slice upload 2026-03-08 09:42:07 +01:00
Janez T
9ff24c1fcb Fix invalid objective_c framework 2026-03-08 09:40:10 +01:00
Janez Troha
948a22c4d2 Merge pull request #16 from MGJ520/main
Update Chinese translation
2026-03-08 09:33:25 +01:00
Janez T
05153c9ceb Clarify path_provider override 2026-03-08 09:30:30 +01:00
Janez T
cd508d5f74 Modernize device settings screen 2026-03-08 09:24:26 +01:00
Janez T
937f91e496 Preserve contact telemetry data 2026-03-08 09:23:07 +01:00
Janez T
1f826ae4c2 Set simple mode default 2026-03-08 09:19:13 +01:00
Janez T
2e15ccb3f3 Fix outdated packages 2026-03-08 09:11:30 +01:00
MGJ
aa0777d4a3 Update Chinese translation 2026-03-08 12:39:57 +08:00
Janez T
e904d53d8b Bump Flutter and iOS build versions 2026-03-07 21:13:44 +01:00
Janez T
c4aef05dcf Use flutter_map main source 2026-03-07 21:10:54 +01:00
Janez T
1c3af4f927 Upgrade flutter packages and limit 2026-03-07 20:59:01 +01:00
Janez T
fe2e08c4e4 Add faster location update channel 2026-03-07 20:38:49 +01:00
Janez T
3433eae3a6 Remove unused path_provider entry 2026-03-07 20:23:09 +01:00
Janez T
79c8664d04 Remove iOS critical alert flags 2026-03-07 20:09:07 +01:00
Janez T
162376cc47 Add ITSAppUsesNonExemptEncryption 2026-03-07 20:05:31 +01:00
Janez Troha
adb8fe32f4 Merge pull request #14 from ydev83/main
Add Russian localization
2026-03-07 20:03:45 +01:00
Janez T
5966ddfbc1 Fix annoying plist reset 2026-03-07 20:00:32 +01:00
Janez T
1867f815d2 Modernize contact list pills 2026-03-07 18:41:37 +01:00
Janez T
4010680da0 Fix contact route parse usage 2026-03-07 18:36:38 +01:00
Janez T
6bfc08f3ae Keep contact GPS location saved 2026-03-07 18:30:36 +01:00
Janez Troha
fa37a20fc7 Merge pull request #15 from MGJ520/main
Fix the Chinese translation switching issue
2026-03-07 18:08:49 +01:00
MGJ
09f61740d2 Merge remote-tracking branch 'origin/main' 2026-03-07 22:18:06 +08:00
MGJ
97c1f3b27d Fix the Chinese translation switching issue 2026-03-07 22:17:53 +08:00
Janez T
cf70aa2bfb Reduce header space and tweak chat 2026-03-07 14:53:22 +01:00
Janez T
40e4db7b7f Remove showDirectMessageDialog 2026-03-07 14:24:45 +01:00
Janez T
2c2e7b392a Remove showDirectMessageDialog usage 2026-03-07 14:24:10 +01:00
ydev83
fd7d6afb70 Merge branch 'dz0ny:main' into main 2026-03-07 23:18:51 +10:00
Janez T
3307a93640 Add avatars display on map 2026-03-07 14:17:52 +01:00
ydev83
8a4b29b968 Add files via upload 2026-03-07 23:15:15 +10:00
Janez T
0e4f727e26 Add swarm mode transport doc 2026-03-07 14:13:54 +01:00
Janez T
4e76898c8d Update pubspec version 2026-03-07 11:33:58 +01:00
Janez T
7214c73aea Remove gates and sync pubspec 2026-03-07 11:33:58 +01:00
Janez Troha
beb960cf62 Merge pull request #12 from MGJ520/main
Fix the Chinese translation switching issue
2026-03-07 09:32:32 +01:00
Janez T
30cac1bcbf Remove 100ms BLE command delay 2026-03-07 09:30:10 +01:00
Janez T
95e6aa35e2 Remove 100ms BLE command delay 2026-03-07 09:26:13 +01:00
Janez T
2bc0e21cf0 Remove 100ms BLE command delay 2026-03-07 09:23:29 +01:00
Janez T
f6c5a3a4ca Add transmission timing details 2026-03-07 09:05:55 +01:00
Janez T
810897d348 Refactor message header pill 2026-03-07 08:39:32 +01:00
Janez T
bb1c1f455d Refine message composer layout 2026-03-07 08:31:09 +01:00
Janez T
b0eded3780 Fix message bubble layout 2026-03-07 08:28:22 +01:00
Janez T
f1d56e2000 Refactor messages tab UI 2026-03-07 08:16:28 +01:00
MGJ
01afcd2ea7 Fix the Chinese translation switching issue 2026-03-07 11:22:21 +08:00
Janez T
70a6156e8a Update iOS project version 2026-03-06 21:22:51 +01:00
Janez T
1856dce27a Update iOS project version 2026-03-06 21:05:02 +01:00
Janez T
485ae995c3 Commit client and rerun pub get 2026-03-06 20:51:49 +01:00
Janez T
0e1a56d765 Log contact data without revealing 2026-03-06 20:47:02 +01:00
Janez T
ca8e8e6ecb Increase cache for public repeaters 2026-03-06 20:29:30 +01:00
Janez T
43f5702666 Add conservative ACK timeout 2026-03-06 20:27:05 +01:00
Janez T
85d0b26c67 Add retransmission handling 2026-03-06 20:12:12 +01:00
Janez T
7cd5351921 Improve message input layout 2026-03-06 20:01:56 +01:00
Janez T
96feb75712 feat: save all pending work
ref:
2026-03-06 20:01:56 +01:00
Janez T
f6779e8776 fix: clean up messages composer bar
ref:
2026-03-06 20:01:56 +01:00
Janez Troha
a9d062dc34 Merge pull request #11 from MGJ520/main
Fix the Chinese translation switching issue
2026-03-06 16:23:51 +01:00
MGJ
3680b38302 Fix the Chinese translation switching issue 2026-03-06 20:10:26 +08:00
161 changed files with 40500 additions and 28999 deletions

View File

@@ -12,7 +12,7 @@ permissions:
id-token: write
env:
FLUTTER_VERSION: "3.35.6"
FLUTTER_VERSION: "3.41.4"
APP_NAME: meshcore-sar
ANDROID_NDK_VERSION: "27.0.12077973"
@@ -280,7 +280,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

@@ -10,7 +10,7 @@ on:
workflow_dispatch:
env:
FLUTTER_VERSION: '3.35.6'
FLUTTER_VERSION: '3.41.4'
jobs:
analyze:

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

@@ -23,6 +23,7 @@
<!-- Microphone for voice messages -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<!-- Notifications -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View File

@@ -2,10 +2,11 @@
## 1. Overview
Image mode mirrors the voice on-demand architecture exactly:
Image mode mirrors the voice on-demand architecture exactly, including
swarm-assisted recovery for stalled partial transfers:
- **Control plane (text messages):**
- `IE2:` image envelope announces image availability in chat.
- `IE4:` image envelope announces image availability in chat.
- **Control plane (raw binary request):**
- Binary image fetch request (same raw route as image fragments).
- **Data plane (raw binary packets):**
@@ -14,11 +15,14 @@ Image mode mirrors the voice on-demand architecture exactly:
Images are never broadcast in full to channels. Chat carries only metadata;
pixels are fetched on demand when the user taps the image bubble.
Shared swarm fallback is documented in
[Swarm Mode Technical Design](./swarm-mode-technical.md).
## 2. Key Modules
- `lib/utils/image_message_parser.dart`
- `ImagePacket` (binary fragment format)
- `ImageEnvelope` (`IE2`)
- `ImageEnvelope` (`IE4`)
- `ImageFetchRequest` (binary)
- `fragmentImage()` — split compressed bytes into packets
- `reassembleImage()` — join received fragments into bytes
@@ -27,8 +31,9 @@ pixels are fetched on demand when the user taps the image bubble.
- `lib/providers/image_provider.dart`
- Reassembly sessions, outgoing cache, deferred serving
- Outgoing sessions also registered as complete incoming sessions for immediate local display
- Received partial sessions can be re-served during swarm recovery
- `lib/providers/app_provider.dart`
- Incoming routing for `IE2`, binary image fetch requests, binary `0x49` packets
- Incoming routing for `IE4`, binary image fetch requests, binary `0x49` packets, and raw swarm control payloads
- `lib/widgets/messages/image_message_bubble.dart`
- Square cover thumbnail (up to 256 px); tap-to-load for received images;
progress ring during fetch; full-screen `InteractiveViewer` on tap
@@ -40,9 +45,9 @@ pixels are fetched on demand when the user taps the image bubble.
## 3. Wire Formats
### 3.1 Image Envelope (`IE2`)
### 3.1 Image Envelope (`IE4`)
Prefix: `IE2:` + colon-delimited compact payload (base36 numeric fields)
Prefix: `IE4:` + colon-delimited compact payload (base36 numeric fields)
Fields:
@@ -54,51 +59,50 @@ Fields:
| `w` | base36 | Actual image width after compression (pixels) |
| `h` | base36 | Actual image height after compression (pixels) |
| `bytes` | base36 | Total compressed size in bytes |
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
| `ts` | base36 | Unix timestamp (seconds) |
Compact format:
```text
IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
```
Example (256×171 landscape image, 14 fragments):
```text
IE2:a:0:e:74:4r:1mc:aabbccddeeff:s44we8
IE4:a:0:e:74:4r:1mc
```
Note: `sid` is base36 on wire and expands to 8-hex internally.
`w` and `h` reflect the actual post-compression dimensions, which preserve
the source aspect ratio (contain within the configured max size).
### 3.2 Image Fetch Request (binary)
### 3.2 Image Fetch Request (`IR4` + binary)
Text format:
```text
IR4:{sid}:{want}:{requesterKey6}
```
Binary payload format:
```text
[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
```
| Field | Value |
|------------------|--------------------------|
| `flags` | bit0=1 => request missing indices, else all |
| `requesterKey6` | 6-byte requester key prefix |
| `ts` | unix timestamp seconds (u32) |
### 3.3 Raw Image Packet (data plane)
Binary payload structure:
- Byte 0: magic `0x49` (`'I'`)
- Bytes 1..4: session ID (4 bytes)
- Byte 5: format ID
- Byte 6: fragment index (0-based)
- Byte 7: total fragments
- Bytes 8..N: image data (max 152 bytes per fragment)
- Byte 5: fragment index (0-based)
- Bytes 6..N: image data (max 152 bytes per fragment)
Header is 8 bytes — identical layout to `VoicePacket`.
Header is 6 bytes. Image format and total fragment count come from the `IE4` envelope.
## 4. Compression Pipeline
@@ -152,11 +156,11 @@ only the shorter axis is padded — no cropping occurs.
7. Envelope sent via normal message path:
- Channel: `sendChannelMessage`
- Direct: `sendTextMessage`
8. Local placeholder message added (`IE2:` text, `deliveryStatus.sending`).
8. Local placeholder message added (`IE4:` text, `deliveryStatus.sending`).
## 6. Incoming Flow (Receive)
### 6.1 `IE2` envelope received
### 6.1 `IE4` envelope received
`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to
chat. The bubble shows a grey square placeholder with a download icon.
@@ -165,11 +169,21 @@ chat. The bubble shows a grey square placeholder with a download icon.
`AppProvider` treats it as control-plane only (not added to chat):
- Validates requester key prefix.
- Resolves requester contact.
- Calls `imageProvider.serveSessionTo()` which streams all cached fragments.
### 6.3 Raw packet received (`pushRawData`, magic `0x49`)
### 6.3 Swarm control messages received
`AppProvider` also handles shared raw swarm discovery payloads:
- binary swarm requests advertise which image fragments are still missing
- binary swarm availability responses advertise which fragments another peer can relay
- swarm payloads arrive via `pushRawData` and are intercepted instead of being added to chat
Shared discovery and responder semantics are documented in
[Swarm Mode Technical Design](./swarm-mode-technical.md).
### 6.4 Raw packet received (`pushRawData`, magic `0x49`)
`AppProvider.onRawDataReceived` parses `ImagePacket` binary and calls
`imageProvider.addFragment()`. When the session becomes complete, the bubble
@@ -188,6 +202,9 @@ When `cacheOutgoingSession()` is called it also writes all fragments into
`_sessions[sessionId]`, so the sender sees the image immediately in the bubble
(no tap-to-load required for own messages).
If a peer later receives only part of an image session, those received fragments
can also be served onward to another requester during swarm recovery.
## 8. Display
`ImageMessageBubble` (max width 256 px):
@@ -195,7 +212,8 @@ When `cacheOutgoingSession()` is called it also writes all fragments into
- **Complete session**: `AspectRatio(1.0)``AvifImage.memory(fit: cover)`
square thumbnail; tap → full-screen `InteractiveViewer` with fade transition.
- **Incomplete/missing**: grey square placeholder with download icon;
tap → sends binary fetch request.
tap → sends binary fetch request, with raw swarm fallback if the original
sender path stalls.
- **Loading**: circular progress indicator showing `received/total` count.
- **Error**: broken-image icon.
@@ -213,12 +231,12 @@ Image bubbles and Message Technical Details show an **estimated transmit time**
The estimate is airtime-based (LoRa packet model), not just compressed image size:
- Source inputs:
- `total` fragments and `bytes` from `IE2` envelope
- `total` fragments and `bytes` from `IE4` envelope
- all numeric envelope values are decoded from base36
- `pathLen` from message metadata
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
- Per-fragment payload model:
- `meshHeader(2)` + `pathLen` + `imageHeader(8)` + `fragmentBytes`
- `meshHeader(2)` + `pathLen` + `imageHeader(6)` + `fragmentBytes`
- LoRa airtime:
- standard symbol-time formula (preamble + payload symbols)
- Mesh pacing/hops:
@@ -246,9 +264,12 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
## 11. Operational Constraints
- No firmware changes required (reuses `cmdSendRawData` / `pushRawData`).
- On-demand fetch works only if sender app is online and has cached session.
- On-demand fetch prefers the original sender, but a partial image can also be
completed from alternate peers that already hold matching fragments.
- Raw return path requires a valid direct route to requester.
- Available on iOS and Android (`image_picker` + `flutter_avif`).
- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as image
fetch and fragment delivery.
### 11.1 Raw Binary Routing Semantics
@@ -260,7 +281,28 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
- only nodes on that path relay it;
- it is **not** received by everyone in the mesh.
## 12. High-Level Sequence
## 12. Swarm Fallback Sequence
```mermaid
sequenceDiagram
participant A as Original Sender
participant P as Peer With Fragments
participant N as Reachable Peers
participant B as Receiver App
A->>M: Send IE4 envelope
M->>B: Deliver IE4
B->>A: Direct binary image fetch request
A->>B: Stream raw ImagePacket fragments (partial)
Note over A,B: Sender path stops responding
B->>N: Raw swarm requests with missing image fragment indices
P->>B: Raw swarm availability response
B->>P: Direct binary fetch request for missing subset
P->>B: Stream remaining raw ImagePacket fragments
B->>B: Reassemble completed image
```
## 13. High-Level Sequence
```mermaid
sequenceDiagram
@@ -272,8 +314,8 @@ sequenceDiagram
A->>A: Compress: contain resize → grayscale → PNG → AVIF
A->>A: Fragment into ≤152B packets
A->>A: Cache outgoing + populate local session (immediate display)
A->>M: Send IE2 envelope (actual w×h, fragment count)
M->>B: Deliver IE2
A->>M: Send IE4 envelope (actual w×h, fragment count)
M->>B: Deliver IE4
B->>B: Render grey placeholder bubble
B->>A: Tap → send binary fetch request
A->>B: Stream binary ImagePackets

View File

@@ -0,0 +1,148 @@
# Swarm Mode Technical Design
## 1. Overview
Swarm mode is a shared media-recovery transport used by both voice and image
sessions when the original sender path stops responding after some fragments
have already propagated through the mesh.
It adds a lightweight **swarm discovery plane** on top of the existing
**direct raw-data transfer plane**:
- **Discovery plane (raw custom control payloads):**
- `MediaSwarmRequest`
- `MediaSwarmAvailability`
- **Transfer plane (direct raw binary):**
- existing `VoiceFetchRequest` / `ImageFetchRequest`
- existing `VoicePacket` / `ImagePacket`
Swarm mode does not broadcast media payloads. It fans out raw control requests
to reachable peers, collects raw availability responses, and then fetches media
from the best responder.
## 2. Problem It Solves
Without swarm mode, media fetch is limited to the original sender's direct raw
path. If that sender goes offline, moves, or stops responding, a receiver can
be left with a partial session even when other peers already hold useful
fragments.
Swarm mode lets the receiver discover alternate peers and fetch the missing
subset directly from them.
## 3. Control Messages
### 3.1 Media Swarm Request (binary)
```text
[magic=0x6d][kind=0x01][mediaType:1B][sessionId:4B][requesterKey6:6B][missingCount:1B][missingIndices...]
```
Fields:
- `mediaType``voice` or `image`
- `sessionId` — 8 hex chars
- `requesterKey6` — 12 hex chars identifying the requesting device
- `missingCount``0` means the requester needs all fragments
- `missingIndices` — exact missing fragment indices when `missingCount > 0`
Example:
```text
6d 01 02 de ad be ef aa bb cc dd ee ff 03 00 02 09
```
### 3.2 Media Swarm Availability (binary)
```text
[magic=0x6d][kind=0x02][mediaType:1B][sessionId:4B][requesterKey6:6B][responderKey6:6B][availableCount:1B][availableIndices...]
```
Fields:
- `mediaType``voice` or `image`
- `sessionId` — 8 hex chars
- `requesterKey6` — copied from the swarm request
- `responderKey6` — 12 hex chars identifying the responding peer
- `availableCount``0` means the responder can satisfy the full request
- `availableIndices` — exact fragment indices held when `availableCount > 0`
Example:
```text
6d 02 02 de ad be ef aa bb cc dd ee ff 11 22 33 44 55 66 02 02 09
```
## 4. Requester Behavior
When a voice or image session is incomplete:
1. Prefer the original sender if its direct raw route is healthy.
2. If the original sender path is unavailable or not responding, send a raw
swarm request to reachable peers with the exact missing fragment indices.
3. Wait up to **10 seconds** for raw availability responses.
4. Rank responders by overlap with the current missing set.
5. Skip the original sender when choosing alternate peers.
6. Send a direct binary fetch request to the best responder for only the
missing subset that responder advertised.
Actual media transfer uses the same `cmdSendRawData` / `pushRawData` path as the
swarm control messages.
## 5. Responder Behavior
A peer that receives a swarm request:
- checks whether it has fragments for the requested media session
- replies only if it has at least one requested fragment
- advertises only fragments it actually holds
- may reply from:
- its outgoing cache, or
- a partially/fully received incoming session
This enables torrent-like relay behavior without requiring the peer to be the
original sender.
## 6. Visibility and Routing
- Swarm control payloads are carried by `cmdSendRawData` and received through
`pushRawData`.
- They are intercepted by the app and **must not be surfaced in chat**.
- Discovery is a direct raw fan-out to reachable peers, not a public-channel
broadcast.
- Media packets themselves remain direct-route raw packets.
## 7. Constraints
- No firmware changes are required.
- Swarm mode only helps if at least one peer has already received some useful
fragments.
- Peers can only relay fragments they actually have.
- Alternate peers still need a valid direct raw route back to the requester.
- Swarm mode improves recovery from sender-path failure, but it does not change
the underlying raw-packet size, airtime, or hop constraints.
## 8. High-Level Sequence
```mermaid
sequenceDiagram
participant S as Original Sender
participant P as Peer With Fragments
participant N as Reachable Peers
participant R as Requester
S->>R: Direct raw fragments (partial)
Note over S,R: Original sender path stops responding
R->>N: Raw swarm requests with exact missing fragments
P->>R: Raw swarm availability response
R->>P: Direct binary fetch request for missing subset
P->>R: Direct raw fragments
R->>R: Reassemble completed session
```
## 9. Integration Points
- Voice-specific behavior is summarized in `docs/voice-mode-technical.md`.
- Image-specific behavior is summarized in `docs/image-mode-technical.md`.
- This document is the shared source of truth for swarm discovery and fallback
semantics.

View File

@@ -2,10 +2,11 @@
## 1. Overview
Voice mode uses a **two-plane architecture**:
Voice mode uses a **two-plane architecture** with optional swarm-assisted
recovery:
- **Control plane (text messages):**
- `VE2:` voice envelope announces voice availability in chat.
- `VE3:` voice envelope announces voice availability in chat.
- **Control plane (raw binary request):**
- Binary voice fetch request (same raw route as voice packets).
- **Data plane (raw binary packets):**
@@ -13,11 +14,13 @@ Voice mode uses a **two-plane architecture**:
This design avoids broadcasting full voice payloads to channels/rooms. Chat carries only metadata; audio is fetched on demand when user presses play.
Swarm fallback is documented in [Swarm Mode Technical Design](./swarm-mode-technical.md).
## 2. Key Modules
- `lib/utils/voice_message_parser.dart`
- `VoicePacket` (legacy text + binary packet format)
- `VoiceEnvelope` (`VE2`)
- `VoicePacket` (binary direct-packet format)
- `VoiceEnvelope` (`VE3`)
- `VoiceFetchRequest` (binary)
- `lib/screens/messages_tab.dart`
- Capture/encode voice, cache encoded packets, send envelope only
@@ -25,20 +28,20 @@ This design avoids broadcasting full voice payloads to channels/rooms. Chat carr
- Reassembly/playback sessions
- Outgoing session cache + deferred serving
- `lib/providers/app_provider.dart`
- Incoming routing for `VE2` and binary voice fetch requests
- Incoming routing for `VE3`, binary voice fetch requests, and raw swarm control payloads
- Handles raw packet ingestion
- `lib/widgets/messages/voice_message_bubble.dart`
- Play behavior (immediate play if complete, otherwise fetch + auto-play)
- `lib/providers/messages_provider.dart`
- Message-level voice detection (`VE2` + legacy `V:`)
- Message-level voice detection (`VE3`)
- `lib/services/message_storage_service.dart`
- Persists `isVoice` and `voiceId`
## 3. Wire Formats
### 3.1 Voice Envelope (`VE2`)
### 3.1 Voice Envelope (`VE3`)
Prefix: `VE2:` + colon-delimited compact payload (base36 numeric fields)
Prefix: `VE3:` + colon-delimited compact payload (base36 numeric fields)
Fields:
@@ -46,21 +49,19 @@ Fields:
- `mode` (base36): codec mode ID (`VoicePacketMode.id`)
- `total` (base36): packet count (1..255)
- `durS` (base36): estimated duration in seconds
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
- `ts` (base36): unix timestamp seconds
`sid` is base36 on wire and expands to 8-hex internally.
Compact format:
```text
VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
VE3:{sid}:{mode}:{total}:{durS}
```
Example:
```text
VE2:a:1:4:4:aabbccddeeff:s44we8
VE3:a:1:4:4
```
### 3.2 Voice Fetch Request (binary)
@@ -68,7 +69,7 @@ VE2:a:1:4:4:aabbccddeeff:s44we8
Binary payload format:
```text
[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
```
### 3.3 Raw Voice Packet (data plane)
@@ -77,10 +78,10 @@ Binary payload structure:
- Byte 0: magic `0x56` (`'V'`)
- Bytes 1..4: session ID (4 bytes)
- Byte 5: mode ID
- Byte 6: packet index
- Byte 7: total packets
- Bytes 8..N: codec2 data
- Byte 5: packet index
- Bytes 6..N: codec2 data
Header is 6 bytes. Mode and total packet count come from the `VE3` envelope.
## 4. Outgoing Flow (Send)
@@ -88,27 +89,38 @@ Binary payload structure:
2. Each chunk is codec2-encoded into `VoicePacket` objects.
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`).
5. Sender sends one envelope (`VE2`) through normal message path:
5. Sender sends one envelope (`VE3`) through normal message path:
- channel/room: `sendChannelMessage`
- direct: `sendTextMessage`
6. **No raw audio packets are sent during initial send.**
## 5. Incoming Routing
### 5.1 `VE2` envelope received
### 5.1 `VE3` envelope received
`AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat.
`AppProvider` records sender identity from message metadata, registers the voice
session envelope, marks the message as voice (`isVoice`, `voiceId`), and adds it to chat.
### 5.2 Binary voice fetch request received
`AppProvider` treats it as control-plane only:
- request is not added to chat
- validates requester prefix match against sender metadata
- resolves requester contact via key prefix
- calls `voiceProvider.serveSessionTo(...)`
### 5.3 Raw packet received (`pushRawData`)
### 5.3 Swarm control messages received
`AppProvider` also handles raw swarm control payloads:
- binary swarm requests advertise which voice fragments are still missing
- binary swarm availability responses advertise which fragments a peer can relay
- swarm control payloads arrive via `pushRawData` and are not added to chat history
Swarm semantics are shared with image mode and documented in
[Swarm Mode Technical Design](./swarm-mode-technical.md).
### 5.4 Raw packet received (`pushRawData`)
`AppProvider.onRawDataReceived` parses `VoicePacket` binary and appends to session in `VoiceProvider`.
@@ -118,10 +130,15 @@ In `VoiceMessageBubble`:
- If session already complete: play immediately.
- If incomplete/missing:
1. Resolve sender contact (message sender prefix or `VE2.senderKey6` fallback)
2. Send direct binary fetch request
3. Show requesting state in UI
4. Auto-play when session becomes complete
1. Resolve sender contact from message sender metadata
2. Prefer a direct fetch from the original sender if its raw route is healthy
3. If the sender path does not respond, fan out a raw swarm request with the
exact missing packet indices to reachable peers
4. Wait up to 10 seconds for raw peer availability responses
5. Send a direct binary fetch request to the best alternate peer for the
missing subset it advertised
6. Show requesting state in UI
7. Auto-play when session becomes complete
If sender cannot be resolved or request cannot be sent, bubble remains and shows: **"Voice unavailable right now"**.
@@ -136,10 +153,13 @@ If sender cannot be resolved or request cannot be sent, bubble remains and shows
Serving prerequisites:
- session exists in cache
- session exists in outgoing cache or already-received session state
- `sendRawPacketCallback` configured
- requester has direct path (`outPathLen >= 0`)
Received partial sessions can therefore act as relay sources during swarm
recovery.
## 8. Persistence
`MessageStorageService` now stores and restores:
@@ -167,13 +187,13 @@ Voice bubbles and Message Technical Details show an **estimated transmit time**
The estimate is airtime-based (LoRa packet model), not file-duration-only:
- Source inputs:
- `packetCount` and `durationMs` from `VE2` envelope, or
- `packetCount` and `durationMs` from `VE3` envelope, or
- numeric envelope values decoded from base36
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
- `pathLen` from message metadata
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
- Per-packet payload model:
- `meshHeader(2)` + `pathLen` + `voiceHeader(8)` + `codec2Bytes`
- `meshHeader(2)` + `pathLen` + `voiceHeader(6)` + `codec2Bytes`
- LoRa airtime:
- standard symbol-time formula (preamble + payload symbols)
- Mesh pacing/hops:
@@ -192,9 +212,12 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
## 11. Operational Constraints
- No firmware changes required.
- On-demand fetch works only if sender app is online and has cached session.
- On-demand fetch prefers the original sender, but a partial session can also be
completed from alternate peers that already hold packets.
- Raw return path needs a currently valid direct route to requester.
- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`).
- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as voice
fetch and packet delivery.
### 11.1 Raw Binary Routing Semantics
@@ -206,26 +229,27 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
- only nodes on that path relay it;
- it is **not** received by everyone in the mesh.
## 12. Backward Compatibility
- Legacy `V:` text packet parsing is still supported.
- Message voice detection accepts `VE2` and legacy `V:` formats.
## 13. High-Level Sequence
## 12. High-Level Sequence
```mermaid
sequenceDiagram
participant A as Sender App
participant M as Mesh Chat
participant P as Peer With Packets
participant N as Reachable Peers
participant B as Receiver App
A->>A: Record + encode voice packets
A->>A: Cache session packets (TTL 15m)
A->>M: Send VE2 envelope
M->>B: Deliver VE2
A->>M: Send VE3 envelope
M->>B: Deliver VE3
B->>B: Render voice bubble (metadata only)
B->>A: Send binary fetch request on Play
A->>B: Stream raw VoicePacket packets
A->>B: Stream raw VoicePacket packets (partial)
Note over A,B: Sender path stops responding
B->>N: Raw swarm requests with missing voice packet indices
P->>B: Raw swarm availability response
B->>P: Direct binary fetch request for missing subset
P->>B: Stream remaining raw VoicePacket packets
B->>B: Reassemble session
B->>B: Auto-play when complete
```

View File

@@ -39,5 +39,13 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
# codec2_flutter uses ARM-specific register asm("sp") in debug_alloc.h that
# fails to compile for iOS simulators. Voice codec support is only required
# on real iOS devices, so skip the native Codec2 sources for simulator SDKs.
if target.name == 'codec2_flutter'
target.build_configurations.each do |config|
config.build_settings['EXCLUDED_SOURCE_FILE_NAMES[sdk=iphonesimulator*]'] = '*.c *.cpp'
end
end
end
end

View File

@@ -59,12 +59,11 @@ PODS:
- Flutter
- nsd_ios (0.0.1):
- Flutter
- ObjectBox (4.4.1)
- objectbox_flutter_libs (0.0.1):
- Flutter
- ObjectBox (= 4.4.1)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- record_ios (1.2.0):
@@ -100,8 +99,8 @@ DEPENDENCIES:
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -114,7 +113,6 @@ SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- ObjectBox
- SDWebImage
- SwiftyGif
@@ -145,10 +143,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/image_picker_ios/ios"
nsd_ios:
:path: ".symlinks/plugins/nsd_ios/ios"
objectbox_flutter_libs:
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
record_ios:
@@ -165,7 +163,7 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/vibration/ios"
SPEC CHECKSUMS:
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
@@ -176,13 +174,12 @@ SPEC CHECKSUMS:
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
@@ -193,6 +190,6 @@ SPEC CHECKSUMS:
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
COCOAPODS: 1.16.2

View File

@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 84;
CURRENT_PROJECT_VERSION = 107;
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 = 84;
CURRENT_PROJECT_VERSION = 107;
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 = 84;
CURRENT_PROJECT_VERSION = 107;
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 = 84;
CURRENT_PROJECT_VERSION = 107;
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 = 84;
CURRENT_PROJECT_VERSION = 107;
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 = 84;
CURRENT_PROJECT_VERSION = 107;
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,9 +43,11 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>84</string>
<string>107</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search &amp; Rescue operations</string>
<key>NSBluetoothPeripheralUsageDescription</key>

View File

@@ -5,22 +5,24 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000211">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000243">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.408783">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.511584">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="120.492323">
<testcase classname="fastlane.lanes" name="2: build_app" time="95.467725">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="12.544865">
<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]
&#10;[Application Loader Error Output]: [altool.104B12FA0] Validation failed (409) Invalid executable. The “Runner.app/Frameworks/objective_c.framework/objective_c” executable references an unsupported platform in the arm64 slice. Since your app targets the iOS platform, it should only include executables that support the iOS platform. Simulator platforms arent permitted. (ID: 678497bb-0ad4-4cd7-b636-f9aef1caa269)
" />

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

@@ -29,9 +29,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get disconnect => 'Trennen';
@override
String get scanningForDevices => 'Suche nach Geräten...';
@override
String get noDevicesFound => 'Keine Geräte gefunden';
@@ -63,27 +60,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get failedToGetGpsLocation =>
'GPS-Position konnte nicht abgerufen werden';
@override
String advertisedAtLocation(String latitude, String longitude) {
return 'Position gesendet bei $latitude, $longitude';
}
@override
String failedToAdvertise(String error) {
return 'Senden fehlgeschlagen: $error';
}
@override
String reconnecting(int attempt, int max) {
return 'Wiederverbindung... ($attempt/$max)';
}
@override
String get cancelReconnection => 'Wiederverbindung abbrechen';
@override
String get mapManagement => 'Kartenverwaltung';
@override
String get general => 'Allgemein';
@@ -130,13 +114,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get displayPacketActivity =>
'Paketaktivitätsindikatoren in der oberen Leiste anzeigen';
@override
String get simpleMode => 'Einfacher Modus';
@override
String get simpleModeDescription =>
'Nicht wesentliche Informationen in Nachrichten und Kontakten ausblenden';
@override
String get disableMap => 'Karte deaktivieren';
@@ -150,74 +127,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get chooseLanguage => 'Sprache auswählen';
@override
String get english => 'Englisch';
@override
String get slovenian => 'Slowenisch';
@override
String get croatian => 'Kroatisch';
@override
String get german => 'Deutsch';
@override
String get spanish => 'Spanisch';
@override
String get french => 'Französisch';
@override
String get italian => 'Italienisch';
@override
String get locationBroadcasting => 'Standortübertragung';
@override
String get autoLocationTracking => 'Automatisches Standort-Tracking';
@override
String get automaticallyBroadcastPosition =>
'Positionsaktualisierungen automatisch übertragen';
@override
String get configureTracking => 'Tracking konfigurieren';
@override
String get distanceAndTimeThresholds => 'Entfernungs- und Zeitschwellenwerte';
@override
String get locationTrackingConfiguration => 'Standort-Tracking-Konfiguration';
@override
String get configureWhenLocationBroadcasts =>
'Konfigurieren Sie, wann Standortübertragungen an das Mesh-Netzwerk gesendet werden';
@override
String get minimumDistance => 'Mindestentfernung';
@override
String broadcastAfterMoving(String distance) {
return 'Nur nach Bewegung von $distance Metern übertragen';
}
@override
String get maximumDistance => 'Maximale Entfernung';
@override
String alwaysBroadcastAfterMoving(String distance) {
return 'Immer nach Bewegung von $distance Metern übertragen';
}
@override
String get minimumTimeInterval => 'Minimales Zeitintervall';
@override
String alwaysBroadcastEvery(String duration) {
return 'Immer alle $duration übertragen';
}
@override
String get save => 'Speichern';
@@ -253,12 +162,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get moreInfo => 'Mehr Info';
@override
String get learnMoreAbout => 'Erfahren Sie mehr über MeshCore SAR';
@override
String get developer => 'Entwickler';
@override
String get packageName => 'Paketname';
@@ -335,9 +238,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get refresh => 'Aktualisieren';
@override
String get sendDirectMessage => 'Senden';
@override
String get resetPath => 'Pfad zurücksetzen (Umleitung)';
@@ -457,21 +357,6 @@ class AppLocalizationsDe extends AppLocalizations {
return '$count Zeichnung$plural teilen';
}
@override
String sentDrawingsToRoom(int count, String plural, String roomName) {
return '$count Kartenzeichnung$plural an $roomName gesendet';
}
@override
String sharedDrawingsToRoom(
int success,
int total,
String plural,
String roomName,
) {
return '$success/$total Zeichnung$plural mit $roomName geteilt';
}
@override
String get showReceivedDrawings => 'Empfangene Zeichnungen anzeigen';
@@ -493,9 +378,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get clearAll => 'Alle löschen';
@override
String get noLocalDrawings => 'Keine lokalen Zeichnungen zum Teilen';
@override
String get publicChannel => 'Öffentlicher Kanal';
@@ -505,37 +387,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get storedPermanently => 'Dauerhaft im Raum gespeichert';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return '$count Kartenzeichnung$plural an öffentlichen Kanal gesendet';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return '$success/$total Zeichnungen mit öffentlichem Kanal geteilt';
}
@override
String get notConnectedToDevice => 'Nicht mit Gerät verbunden';
@override
String get directMessage => 'Direktnachricht';
@override
String directMessageSentTo(String contactName) {
return 'Direktnachricht an $contactName gesendet';
}
@override
String failedToSend(String error) {
return 'Senden fehlgeschlagen: $error';
}
@override
String directMessageInfo(String contactName) {
return 'Diese Nachricht wird direkt an $contactName gesendet. Sie erscheint auch im Hauptnachrichten-Feed.';
}
@override
String get typeYourMessage => 'Geben Sie Ihre Nachricht ein...';
@@ -565,9 +419,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get location => 'Standort';
@override
String get myLocation => 'Mein Standort';
@override
String get fromMap => 'Von Karte';
@@ -630,9 +481,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get flood => 'Flut';
@override
String get admin => 'Admin';
@override
String get loggedIn => 'Angemeldet';
@@ -642,29 +490,11 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get distance => 'Entfernung';
@override
String pingingDirect(String name) {
return 'Pinge $name (direkt über Pfad)...';
}
@override
String pingingFlood(String name) {
return 'Pinge $name (Flutung - kein Pfad)...';
}
@override
String directPingTimeout(String name) {
return 'Direkter Ping-Timeout - wiederhole $name mit Flutung...';
}
@override
String pingSuccessful(String name, String fallback) {
return 'Ping erfolgreich an $name$fallback';
}
@override
String get viaFloodingFallback => ' (über Flutungs-Fallback)';
@override
String pingFailed(String name) {
return 'Ping fehlgeschlagen an $name - keine Antwort erhalten';
@@ -675,16 +505,6 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Sind Sie sicher, dass Sie \"$name\" löschen möchten?\n\nDies entfernt den Kontakt sowohl aus der App als auch vom Begleitfunkgerät.';
}
@override
String removingContact(String name) {
return 'Entferne $name...';
}
@override
String contactRemoved(String name) {
return 'Kontakt \"$name\" entfernt';
}
@override
String failedToRemoveContact(String error) {
return 'Fehler beim Entfernen des Kontakts: $error';
@@ -729,11 +549,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get telemetry => 'Telemetrie';
@override
String requestingTelemetry(String name) {
return 'Fordere Telemetrie von $name an...';
}
@override
String get voltage => 'Spannung';
@@ -805,9 +620,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get showAll => 'Alle anzeigen';
@override
String get nearbyContacts => 'Kontakte in der Nähe';
@override
String get locationUnavailable => 'Standort nicht verfügbar';
@@ -826,7 +638,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String latLonFormat(String latitude, String longitude) {
return 'Lat: $latitude Lon: $longitude';
return 'Breite: $latitude Lange: $longitude';
}
@override
@@ -848,175 +660,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get channels => 'Kanäle';
@override
String get cacheStatistics => 'Cache-Statistiken';
@override
String get totalTiles => 'Gesamte Tiles';
@override
String get cacheSize => 'Cache-Größe';
@override
String get storeName => 'Speichername';
@override
String get noCacheStatistics => 'Keine Cache-Statistiken verfügbar';
@override
String get downloadRegion => 'Region herunterladen';
@override
String get mapLayer => 'Kartenebene';
@override
String get regionBounds => 'Regionsgrenzen';
@override
String get north => 'Nord';
@override
String get south => 'Süd';
@override
String get east => 'Ost';
@override
String get west => 'West';
@override
String get zoomLevels => 'Zoom-Stufen';
@override
String minZoom(int zoom) {
return 'Min: $zoom';
}
@override
String maxZoom(int zoom) {
return 'Max: $zoom';
}
@override
String get downloadingDots => 'Lädt herunter...';
@override
String get cancelDownload => 'Download abbrechen';
@override
String get downloadRegionButton => 'Region herunterladen';
@override
String get downloadNote =>
'Hinweis: Große Regionen oder hohe Zoom-Stufen können erhebliche Zeit und Speicherplatz benötigen.';
@override
String get cacheManagement => 'Cache-Verwaltung';
@override
String get clearAllMaps => 'Alle Karten löschen';
@override
String get clearMapsConfirmTitle => 'Alle Karten löschen';
@override
String get clearMapsConfirmMessage =>
'Sind Sie sicher, dass Sie alle heruntergeladenen Karten löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.';
@override
String get mapDownloadCompleted => 'Karten-Download abgeschlossen!';
@override
String get cacheClearedSuccessfully => 'Cache erfolgreich gelöscht!';
@override
String get downloadCancelled => 'Download abgebrochen';
@override
String get startingDownload => 'Starte Download...';
@override
String get downloadingMapTiles => 'Lade Karten-Tiles herunter...';
@override
String get downloadCompletedSuccessfully =>
'Download erfolgreich abgeschlossen!';
@override
String get cancellingDownload => 'Breche Download ab...';
@override
String errorLoadingStats(String error) {
return 'Fehler beim Laden der Statistiken: $error';
}
@override
String downloadFailed(String error) {
return 'Download fehlgeschlagen: $error';
}
@override
String cancelFailed(String error) {
return 'Abbruch fehlgeschlagen: $error';
}
@override
String clearCacheFailed(String error) {
return 'Löschen des Caches fehlgeschlagen: $error';
}
@override
String minZoomError(String error) {
return 'Min-Zoom: $error';
}
@override
String maxZoomError(String error) {
return 'Max-Zoom: $error';
}
@override
String get minZoomGreaterThanMax =>
'Minimaler Zoom muss kleiner oder gleich dem maximalen Zoom sein';
@override
String get selectMapLayer => 'Kartenebene auswählen';
@override
String get mapOptions => 'Kartenoptionen';
@override
String get showLegend => 'Legende anzeigen';
@override
String get displayMarkerTypeCounts => 'Markierungstypen-Anzahl anzeigen';
@override
String get rotateMapWithHeading => 'Karte mit Kurs drehen';
@override
String get mapFollowsDirection =>
'Karte folgt Ihrer Richtung während der Bewegung';
@override
String get resetMapRotation => 'Drehung zurücksetzen';
@override
String get resetMapRotationTooltip => 'Karte nach Norden zurücksetzen';
@override
String get showMapDebugInfo => 'Karten-Debug-Info anzeigen';
@override
String get displayZoomLevelBounds => 'Zoom-Stufe und Grenzen anzeigen';
@override
String get fullscreenMode => 'Vollbildmodus';
@override
String get hideUiFullMapView =>
'Alle UI-Steuerelemente für volle Kartenansicht ausblenden';
@override
String get openStreetMap => 'OpenStreetMap';
@@ -1035,12 +681,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get googleTerrain => 'Google Gelände';
@override
String get downloadVisibleArea => 'Sichtbaren Bereich herunterladen';
@override
String get initializingMap => 'Initialisiere Karte...';
@override
String get dragToPosition => 'Zur Position ziehen';
@@ -1056,53 +696,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get sarAlert => 'SAR-ALARM';
@override
String get messageSentToPublicChannel =>
'Nachricht an öffentlichen Kanal gesendet';
@override
String get pleaseSelectRoomToSendSar =>
'Bitte wählen Sie einen Raum zum Senden der SAR-Markierung';
@override
String failedToSendSarMarker(String error) {
return 'Fehler beim Senden der SAR-Markierung: $error';
}
@override
String sarMarkerSentTo(String roomName) {
return 'SAR-Markierung an $roomName gesendet';
}
@override
String get notConnectedCannotSync =>
'Nicht verbunden - Nachrichten können nicht synchronisiert werden';
@override
String syncedMessageCount(int count) {
return '$count Nachricht(en) synchronisiert';
}
@override
String get noNewMessages => 'Keine neuen Nachrichten';
@override
String syncFailed(String error) {
return 'Synchronisation fehlgeschlagen: $error';
}
@override
String get failedToResendMessage =>
'Fehler beim erneuten Senden der Nachricht';
@override
String get retryingMessage => 'Wiederhole Nachricht...';
@override
String retryFailed(String error) {
return 'Wiederholen fehlgeschlagen: $error';
}
@override
String get textCopiedToClipboard => 'Text in Zwischenablage kopiert';
@@ -1114,9 +707,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get cannotReplyContactNotFound =>
'Antwort nicht möglich: Kontakt nicht gefunden';
@override
String get messageDeleted => 'Nachricht gelöscht';
@override
String get copyText => 'Text kopieren';
@@ -1153,12 +743,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get sarLocationShare => 'SAR Standort';
@override
String get locationShared => 'Standort geteilt';
@override
String get refreshedContacts => 'Kontakte aktualisiert';
@override
String get justNow => 'Gerade eben';
@@ -1200,7 +784,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get failed => 'Fehlgeschlagen';
@override
String get broadcast => 'Broadcast';
String get broadcast => 'Rundspruch';
@override
String deliveredToContacts(int delivered, int total) {
@@ -1260,130 +844,28 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String maxPowerDbm(int power) {
return 'Max: $power dBm';
return 'Max.: $power dBm';
}
@override
String get you => 'Du';
@override
String get offlineVectorMaps => 'Offline-Vektorkarten';
@override
String get offlineVectorMapsDescription =>
'Importieren und verwalten Sie Offline-Vektorkarten-Tiles (MBTiles-Format) zur Verwendung ohne Internetverbindung';
@override
String get importMbtiles => 'MBTiles-Datei importieren';
@override
String get importMbtilesNote =>
'Unterstützt MBTiles-Dateien mit Vektor-Tiles (PBF/MVT-Format). Geofabrik-Auszüge funktionieren hervorragend!';
@override
String get noMbtilesFiles => 'Keine Offline-Vektorkarten gefunden';
@override
String get mbtilesImportedSuccessfully =>
'MBTiles-Datei erfolgreich importiert';
@override
String get failedToImportMbtiles =>
'Fehler beim Importieren der MBTiles-Datei';
@override
String get deleteMbtilesConfirmTitle => 'Offline-Karte löschen';
@override
String deleteMbtilesConfirmMessage(String name) {
return 'Sind Sie sicher, dass Sie \"$name\" löschen möchten? Dies entfernt die Offline-Karte dauerhaft.';
}
@override
String get mbtilesDeletedSuccessfully => 'Offline-Karte erfolgreich gelöscht';
@override
String get failedToDeleteMbtiles => 'Fehler beim Löschen der Offline-Karte';
@override
String get importExportCachedTiles => 'Import/Export gecachter Kacheln';
@override
String get importExportDescription =>
'Sichern, teilen und wiederherstellen Sie heruntergeladene Kartenkacheln zwischen Geräten';
@override
String get exportTilesToFile => 'Kacheln in Datei exportieren';
@override
String get importTilesFromFile => 'Kacheln aus Datei importieren';
@override
String get selectExportLocation => 'Exportspeicherort wählen';
@override
String get selectImportFile => 'Kachel-Archiv auswählen';
@override
String get exportingTiles => 'Exportiere Kacheln...';
@override
String get importingTiles => 'Importiere Kacheln...';
@override
String exportSuccess(int count) {
return '$count Kacheln erfolgreich exportiert';
}
@override
String importSuccess(int count) {
return '$count Speicher erfolgreich importiert';
}
@override
String exportFailed(String error) {
return 'Export failed: $error';
return 'Export fehlgeschlagen: $error';
}
@override
String importFailed(String error) {
return 'Import failed: $error';
return 'Import fehlgeschlagen: $error';
}
@override
String get exportNote =>
'Erstellt eine komprimierte Archivdatei (.fmtc), die auf anderen Geräten geteilt und importiert werden kann.';
@override
String get importNote =>
'Importiert Kartenkacheln aus einer zuvor exportierten Archivdatei. Kacheln werden mit dem vorhandenen Cache zusammengeführt.';
@override
String get noTilesToExport => 'Keine Kacheln zum Exportieren verfügbar';
@override
String archiveContainsStores(int count) {
return 'Archiv enthält $count Speicher';
}
@override
String get vectorTiles => 'Vektor-Tiles';
@override
String get schema => 'Schema';
@override
String get unknown => 'Unbekannt';
@override
String get bounds => 'Grenzen';
@override
String get onlineLayers => 'Online-Ebenen';
@override
String get offlineLayers => 'Offline-Ebenen';
@override
String get locationTrail => 'Standortverlauf';
@@ -1423,47 +905,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get trailControls => 'Verlaufssteuerung';
@override
String get exportTrailToGpx => 'Verlauf als GPX exportieren';
@override
String get importTrailFromGpx => 'Verlauf aus GPX importieren';
@override
String get trailExportedSuccessfully => 'Verlauf erfolgreich exportiert!';
@override
String get failedToExportTrail => 'Exportieren des Verlaufs fehlgeschlagen';
@override
String failedToImportTrail(String error) {
return 'Importieren des Verlaufs fehlgeschlagen: $error';
}
@override
String get importTrail => 'Verlauf importieren';
@override
String importTrailQuestion(int pointCount) {
return 'Verlauf mit $pointCount Punkten importieren?\n\nSie können Ihren aktuellen Verlauf ersetzen oder daneben anzeigen.';
}
@override
String get viewAlongside => 'Daneben anzeigen';
@override
String get replaceCurrent => 'Aktuellen ersetzen';
@override
String trailImported(int pointCount) {
return 'Verlauf importiert! ($pointCount Punkte)';
}
@override
String trailReplaced(int pointCount) {
return 'Verlauf ersetzt! ($pointCount Punkte)';
}
@override
String get contactTrails => 'Kontaktverläufe';
@@ -1526,10 +967,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get telemetryAndLocationSharing => 'Telemetrie & Standortfreigabe';
@override
String get lat => 'Lat';
String get lat => 'Breite';
@override
String get lon => 'Lon';
String get lon => 'Lange';
@override
String get useCurrentLocation => 'Aktuellen Standort verwenden';
@@ -1541,7 +982,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get chatNode => 'Chat-Knoten';
@override
String get repeater => 'Repeater';
String get repeater => 'Relais';
@override
String get roomChannel => 'Raum/Kanal';
@@ -1585,7 +1026,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get templateName => 'Vorlagenname';
@override
String get templateNameHint => 'e.g. Found Person';
String get templateNameHint => 'z. B. Gefundene Person';
@override
String get templateEmoji => 'Emoji';
@@ -1597,16 +1038,16 @@ class AppLocalizationsDe extends AppLocalizations {
String get nameRequired => 'Name ist erforderlich';
@override
String get templateDescription => 'Description (Optional)';
String get templateDescription => 'Beschreibung (optional)';
@override
String get templateDescriptionHint => 'Add additional context...';
String get templateDescriptionHint => 'Zusatzlichen Kontext hinzufugen...';
@override
String get templateColor => 'Color';
String get templateColor => 'Farbe';
@override
String get previewFormat => 'Preview (SAR Message Format)';
String get previewFormat => 'Vorschau (SAR-Nachrichtenformat)';
@override
String get importFromClipboard => 'Importieren';
@@ -1616,26 +1057,26 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String deleteTemplateConfirmation(String name) {
return 'Delete template \'$name\'?';
return 'Vorlage \'$name\' loschen?';
}
@override
String get templateAdded => 'Template added';
String get templateAdded => 'Vorlage hinzugefugt';
@override
String get templateUpdated => 'Template updated';
String get templateUpdated => 'Vorlage aktualisiert';
@override
String get templateDeleted => 'Template deleted';
String get templateDeleted => 'Vorlage geloscht';
@override
String templatesImported(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Imported $count templates',
one: 'Imported 1 template',
zero: 'No templates imported',
other: '$count Vorlagen importiert',
one: '1 Vorlage importiert',
zero: 'Keine Vorlagen importiert',
);
return '$_temp0';
}
@@ -1645,8 +1086,8 @@ class AppLocalizationsDe extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Exported $count templates to clipboard',
one: 'Exported 1 template to clipboard',
other: '$count Vorlagen in die Zwischenablage exportiert',
one: '1 Vorlage in die Zwischenablage exportiert',
);
return '$_temp0';
}
@@ -1665,10 +1106,11 @@ class AppLocalizationsDe extends AppLocalizations {
String get resetComplete => 'Vorlagen auf Standard zurückgesetzt';
@override
String get noTemplates => 'No templates available';
String get noTemplates => 'Keine Vorlagen verfugbar';
@override
String get tapAddToCreate => 'Tap + to create your first template';
String get tapAddToCreate =>
'Tippen Sie auf +, um Ihre erste Vorlage zu erstellen';
@override
String get ok => 'OK';
@@ -1736,20 +1178,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get noRoomsFound => 'Keine Räume gefunden';
@override
String get noContactsOrRoomsAvailable =>
'Keine Kontakte oder Räume verfügbar';
@override
String get noRecipientsAvailable => 'Keine Empfänger verfügbar';
@override
String get noChannelsFound => 'Keine Kanäle gefunden';
@override
String get messagesWillBeSentToPublicChannel =>
'Nachrichten werden an öffentlichen Kanal gesendet';
@override
String get newMessage => 'Neue Nachricht';
@@ -1932,10 +1366,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get rectangleDrawing => 'Rechteck';
@override
String get coordinatesCopiedToClipboard =>
'Koordinaten in Zwischenablage kopiert';
@override
String get manualCoordinates => 'Manuelle Koordinaten';
@@ -1948,31 +1378,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get longitudeLabel => 'Längengrad';
@override
String get invalidLatitude => 'Ungültiger Breitengrad (-90 bis 90)';
@override
String get invalidLongitude => 'Ungültiger Längengrad (-180 bis 180)';
@override
String get exampleCoordinates => 'Beispiel: 46.0569, 14.5058';
@override
String get drawingShared => 'Kartenzeichnung';
@override
String get drawingHidden => 'Zeichnung von Karte ausgeblendet';
@override
String alreadyShared(int count) {
return '$count bereits geteilt';
}
@override
String newDrawingsShared(int count, String plural) {
return '$count neue Zeichnung(en) geteilt';
}
@override
String get shareDrawing => 'Zeichnung teilen';
@@ -2028,12 +1436,6 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get forestRoads => 'Waldwege';
@override
String get showCadastralParcels => 'Katasterparzellen anzeigen';
@override
String get showForestRoads => 'Waldwege anzeigen';
@override
String get wmsOverlays => 'WMS Überlagerungen';
@@ -2138,10 +1540,6 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Kanal konnte nicht gelöscht werden: $error';
}
@override
String get allChannelSlotsInUse =>
'Alle Kanalplätze sind belegt (maximal 39 benutzerdefinierte Kanäle)';
@override
String get createChannel => 'Kanal erstellen';
@@ -2183,21 +1581,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get wizardConnectingFeature3 =>
'Funktioniert vollständig offline - kein Internet erforderlich';
@override
String get wizardSimpleModeTitle => 'Einfacher Modus';
@override
String get wizardSimpleModeDescription =>
'Neu im Mesh-Netzwerk? Aktivieren Sie den einfachen Modus für eine optimierte Benutzeroberfläche mit nur wesentlichen Funktionen.';
@override
String get wizardSimpleModeFeature1 =>
'Anfängerfreundliche Benutzeroberfläche mit Kernfunktionen';
@override
String get wizardSimpleModeFeature2 =>
'Jederzeit in den erweiterten Modus in den Einstellungen wechseln';
@override
String get wizardChannelTitle => 'Kanäle';

File diff suppressed because it is too large Load Diff

View File

@@ -29,9 +29,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get disconnect => 'Disconnect';
@override
String get scanningForDevices => 'Scanning for devices...';
@override
String get noDevicesFound => 'No devices found';
@@ -62,27 +59,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get failedToGetGpsLocation => 'Failed to get GPS location';
@override
String advertisedAtLocation(String latitude, String longitude) {
return 'Advertised at $latitude, $longitude';
}
@override
String failedToAdvertise(String error) {
return 'Failed to advertise: $error';
}
@override
String reconnecting(int attempt, int max) {
return 'Reconnecting... ($attempt/$max)';
}
@override
String get cancelReconnection => 'Cancel reconnection';
@override
String get mapManagement => 'Map Management';
@override
String get general => 'General';
@@ -129,13 +113,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get displayPacketActivity =>
'Display packet activity indicators in top bar';
@override
String get simpleMode => 'Simple Mode';
@override
String get simpleModeDescription =>
'Hide non-essential information in messages and contacts';
@override
String get disableMap => 'Disable Map';
@@ -149,74 +126,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get chooseLanguage => 'Choose Language';
@override
String get english => 'English';
@override
String get slovenian => 'Slovenian';
@override
String get croatian => 'Croatian';
@override
String get german => 'German';
@override
String get spanish => 'Spanish';
@override
String get french => 'French';
@override
String get italian => 'Italian';
@override
String get locationBroadcasting => 'Location Broadcasting';
@override
String get autoLocationTracking => 'Auto Location Tracking';
@override
String get automaticallyBroadcastPosition =>
'Automatically broadcast position updates';
@override
String get configureTracking => 'Configure Tracking';
@override
String get distanceAndTimeThresholds => 'Distance and time thresholds';
@override
String get locationTrackingConfiguration => 'Location Tracking Configuration';
@override
String get configureWhenLocationBroadcasts =>
'Configure when location broadcasts are sent to the mesh network';
@override
String get minimumDistance => 'Minimum Distance';
@override
String broadcastAfterMoving(String distance) {
return 'Broadcast only after moving $distance meters';
}
@override
String get maximumDistance => 'Maximum Distance';
@override
String alwaysBroadcastAfterMoving(String distance) {
return 'Always broadcast after moving $distance meters';
}
@override
String get minimumTimeInterval => 'Minimum Time Interval';
@override
String alwaysBroadcastEvery(String duration) {
return 'Always broadcast every $duration';
}
@override
String get save => 'Save';
@@ -252,12 +161,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get moreInfo => 'More Info';
@override
String get learnMoreAbout => 'Learn more about MeshCore SAR';
@override
String get developer => 'Developer';
@override
String get packageName => 'Package Name';
@@ -333,9 +236,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get refresh => 'Refresh';
@override
String get sendDirectMessage => 'Send';
@override
String get resetPath => 'Reset Path (Re-route)';
@@ -455,21 +355,6 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Share $count Drawing$plural';
}
@override
String sentDrawingsToRoom(int count, String plural, String roomName) {
return 'Sent $count map drawing$plural to $roomName';
}
@override
String sharedDrawingsToRoom(
int success,
int total,
String plural,
String roomName,
) {
return 'Shared $success/$total drawing$plural to $roomName';
}
@override
String get showReceivedDrawings => 'Show Received Drawings';
@@ -491,9 +376,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get clearAll => 'Clear All';
@override
String get noLocalDrawings => 'No local drawings to share';
@override
String get publicChannel => 'Public Channel';
@@ -503,37 +385,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get storedPermanently => 'Stored permanently in room';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return 'Sent $count map drawing$plural to Public Channel';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return 'Shared $success/$total drawings to Public Channel';
}
@override
String get notConnectedToDevice => 'Not connected to device';
@override
String get directMessage => 'Direct Message';
@override
String directMessageSentTo(String contactName) {
return 'Direct message sent to $contactName';
}
@override
String failedToSend(String error) {
return 'Failed to send: $error';
}
@override
String directMessageInfo(String contactName) {
return 'This message will be sent directly to $contactName. It will also appear in the main messages feed.';
}
@override
String get typeYourMessage => 'Type your message...';
@@ -563,9 +417,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get location => 'Location';
@override
String get myLocation => 'My Location';
@override
String get fromMap => 'From Map';
@@ -627,9 +478,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get flood => 'Flood';
@override
String get admin => 'Admin';
@override
String get loggedIn => 'Logged In';
@@ -639,29 +487,11 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get distance => 'Distance';
@override
String pingingDirect(String name) {
return 'Pinging $name (direct via path)...';
}
@override
String pingingFlood(String name) {
return 'Pinging $name (flooding - no path)...';
}
@override
String directPingTimeout(String name) {
return 'Direct ping timeout - retrying $name with flooding...';
}
@override
String pingSuccessful(String name, String fallback) {
return 'Ping successful to $name$fallback';
}
@override
String get viaFloodingFallback => ' (via flooding fallback)';
@override
String pingFailed(String name) {
return 'Ping failed to $name - no response received';
@@ -672,16 +502,6 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Are you sure you want to delete \"$name\"?\n\nThis will remove the contact from both the app and the companion radio device.';
}
@override
String removingContact(String name) {
return 'Removing $name...';
}
@override
String contactRemoved(String name) {
return 'Contact \"$name\" removed';
}
@override
String failedToRemoveContact(String error) {
return 'Failed to remove contact: $error';
@@ -726,11 +546,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get telemetry => 'Telemetry';
@override
String requestingTelemetry(String name) {
return 'Requesting telemetry from $name...';
}
@override
String get voltage => 'Voltage';
@@ -802,9 +617,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get showAll => 'Show All';
@override
String get nearbyContacts => 'Nearby Contacts';
@override
String get locationUnavailable => 'Location unavailable';
@@ -845,173 +657,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get channels => 'Channels';
@override
String get cacheStatistics => 'Cache Statistics';
@override
String get totalTiles => 'Total Tiles';
@override
String get cacheSize => 'Cache Size';
@override
String get storeName => 'Store Name';
@override
String get noCacheStatistics => 'No cache statistics available';
@override
String get downloadRegion => 'Download Region';
@override
String get mapLayer => 'Map Layer';
@override
String get regionBounds => 'Region Bounds';
@override
String get north => 'North';
@override
String get south => 'South';
@override
String get east => 'East';
@override
String get west => 'West';
@override
String get zoomLevels => 'Zoom Levels';
@override
String minZoom(int zoom) {
return 'Min: $zoom';
}
@override
String maxZoom(int zoom) {
return 'Max: $zoom';
}
@override
String get downloadingDots => 'Downloading...';
@override
String get cancelDownload => 'Cancel Download';
@override
String get downloadRegionButton => 'Download Region';
@override
String get downloadNote =>
'Note: Large regions or high zoom levels may take significant time and storage.';
@override
String get cacheManagement => 'Cache Management';
@override
String get clearAllMaps => 'Clear All Maps';
@override
String get clearMapsConfirmTitle => 'Clear All Maps';
@override
String get clearMapsConfirmMessage =>
'Are you sure you want to delete all downloaded maps? This action cannot be undone.';
@override
String get mapDownloadCompleted => 'Map download completed!';
@override
String get cacheClearedSuccessfully => 'Cache cleared successfully!';
@override
String get downloadCancelled => 'Download cancelled';
@override
String get startingDownload => 'Starting download...';
@override
String get downloadingMapTiles => 'Downloading map tiles...';
@override
String get downloadCompletedSuccessfully =>
'Download completed successfully!';
@override
String get cancellingDownload => 'Cancelling download...';
@override
String errorLoadingStats(String error) {
return 'Error loading stats: $error';
}
@override
String downloadFailed(String error) {
return 'Download failed: $error';
}
@override
String cancelFailed(String error) {
return 'Cancel failed: $error';
}
@override
String clearCacheFailed(String error) {
return 'Clear cache failed: $error';
}
@override
String minZoomError(String error) {
return 'Min zoom: $error';
}
@override
String maxZoomError(String error) {
return 'Max zoom: $error';
}
@override
String get minZoomGreaterThanMax =>
'Minimum zoom must be less than or equal to maximum zoom';
@override
String get selectMapLayer => 'Select Map Layer';
@override
String get mapOptions => 'Map Options';
@override
String get showLegend => 'Show Legend';
@override
String get displayMarkerTypeCounts => 'Display marker type counts';
@override
String get rotateMapWithHeading => 'Rotate Map with Heading';
@override
String get mapFollowsDirection => 'Map follows your direction when moving';
@override
String get resetMapRotation => 'Reset Rotation';
@override
String get resetMapRotationTooltip => 'Reset map to north';
@override
String get showMapDebugInfo => 'Show Map Debug Info';
@override
String get displayZoomLevelBounds => 'Display zoom level and bounds';
@override
String get fullscreenMode => 'Fullscreen Mode';
@override
String get hideUiFullMapView => 'Hide all UI controls for full map view';
@override
String get openStreetMap => 'OpenStreetMap';
@@ -1030,12 +678,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get googleTerrain => 'Google Terrain';
@override
String get downloadVisibleArea => 'Download visible area';
@override
String get initializingMap => 'Initializing map...';
@override
String get dragToPosition => 'Drag to Position';
@@ -1051,50 +693,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get sarAlert => 'SAR ALERT';
@override
String get messageSentToPublicChannel => 'Message sent to public channel';
@override
String get pleaseSelectRoomToSendSar =>
'Please select a room to send SAR marker';
@override
String failedToSendSarMarker(String error) {
return 'Failed to send SAR marker: $error';
}
@override
String sarMarkerSentTo(String roomName) {
return 'SAR marker sent to $roomName';
}
@override
String get notConnectedCannotSync => 'Not connected - cannot sync messages';
@override
String syncedMessageCount(int count) {
return 'Synced $count message(s)';
}
@override
String get noNewMessages => 'No new messages';
@override
String syncFailed(String error) {
return 'Sync failed: $error';
}
@override
String get failedToResendMessage => 'Failed to resend message';
@override
String get retryingMessage => 'Retrying message...';
@override
String retryFailed(String error) {
return 'Retry failed: $error';
}
@override
String get textCopiedToClipboard => 'Text copied to clipboard';
@@ -1105,9 +703,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cannotReplyContactNotFound => 'Cannot reply: contact not found';
@override
String get messageDeleted => 'Message deleted';
@override
String get copyText => 'Copy text';
@@ -1143,12 +738,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get sarLocationShare => 'SAR Location';
@override
String get locationShared => 'Location shared';
@override
String get refreshedContacts => 'Refreshed contacts';
@override
String get justNow => 'Just now';
@@ -1256,79 +845,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get you => 'You';
@override
String get offlineVectorMaps => 'Offline Vector Maps';
@override
String get offlineVectorMapsDescription =>
'Import and manage offline vector map tiles (MBTiles format) for use without internet connection';
@override
String get importMbtiles => 'Import MBTiles File';
@override
String get importMbtilesNote =>
'Supports MBTiles files with vector tiles (PBF/MVT format). Geofabrik extracts work great!';
@override
String get noMbtilesFiles => 'No offline vector maps found';
@override
String get mbtilesImportedSuccessfully =>
'MBTiles file imported successfully';
@override
String get failedToImportMbtiles => 'Failed to import MBTiles file';
@override
String get deleteMbtilesConfirmTitle => 'Delete Offline Map';
@override
String deleteMbtilesConfirmMessage(String name) {
return 'Are you sure you want to delete \"$name\"? This will permanently remove the offline map.';
}
@override
String get mbtilesDeletedSuccessfully => 'Offline map deleted successfully';
@override
String get failedToDeleteMbtiles => 'Failed to delete offline map';
@override
String get importExportCachedTiles => 'Import/Export Cached Tiles';
@override
String get importExportDescription =>
'Backup, share, and restore downloaded map tiles between devices';
@override
String get exportTilesToFile => 'Export Tiles to File';
@override
String get importTilesFromFile => 'Import Tiles from File';
@override
String get selectExportLocation => 'Select Export Location';
@override
String get selectImportFile => 'Select Tile Archive';
@override
String get exportingTiles => 'Exporting tiles...';
@override
String get importingTiles => 'Importing tiles...';
@override
String exportSuccess(int count) {
return 'Exported $count tiles successfully';
}
@override
String importSuccess(int count) {
return 'Imported $count stores successfully';
}
@override
String exportFailed(String error) {
return 'Export failed: $error';
@@ -1339,40 +855,12 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Import failed: $error';
}
@override
String get exportNote =>
'Creates a compressed archive (.fmtc) file that can be shared and imported on other devices.';
@override
String get importNote =>
'Imports map tiles from a previously exported archive file. Tiles will be merged with existing cache.';
@override
String get noTilesToExport => 'No tiles available to export';
@override
String archiveContainsStores(int count) {
return 'Archive contains $count stores';
}
@override
String get vectorTiles => 'Vector Tiles';
@override
String get schema => 'Schema';
@override
String get unknown => 'Unknown';
@override
String get bounds => 'Bounds';
@override
String get onlineLayers => 'Online Layers';
@override
String get offlineLayers => 'Offline Layers';
@override
String get locationTrail => 'Location Trail';
@@ -1411,47 +899,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get trailControls => 'Trail Controls';
@override
String get exportTrailToGpx => 'Export Trail to GPX';
@override
String get importTrailFromGpx => 'Import Trail from GPX';
@override
String get trailExportedSuccessfully => 'Trail exported successfully!';
@override
String get failedToExportTrail => 'Failed to export trail';
@override
String failedToImportTrail(String error) {
return 'Failed to import trail: $error';
}
@override
String get importTrail => 'Import Trail';
@override
String importTrailQuestion(int pointCount) {
return 'Import trail with $pointCount points?\n\nYou can replace your current trail or view it alongside.';
}
@override
String get viewAlongside => 'View Alongside';
@override
String get replaceCurrent => 'Replace Current';
@override
String trailImported(int pointCount) {
return 'Trail imported! ($pointCount points)';
}
@override
String trailReplaced(int pointCount) {
return 'Trail replaced! ($pointCount points)';
}
@override
String get contactTrails => 'Contact Trails';
@@ -1721,19 +1168,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get noRoomsFound => 'No rooms found';
@override
String get noContactsOrRoomsAvailable => 'No contacts or rooms available';
@override
String get noRecipientsAvailable => 'No recipients available';
@override
String get noChannelsFound => 'No channels found';
@override
String get messagesWillBeSentToPublicChannel =>
'Messages will be sent to public channel';
@override
String get newMessage => 'New message';
@@ -1908,9 +1348,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get rectangleDrawing => 'Rectangle Drawing';
@override
String get coordinatesCopiedToClipboard => 'Coordinates copied to clipboard';
@override
String get manualCoordinates => 'Manual Coordinates';
@@ -1923,37 +1360,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get longitudeLabel => 'Longitude';
@override
String get invalidLatitude => 'Invalid latitude (-90 to 90)';
@override
String get invalidLongitude => 'Invalid longitude (-180 to 180)';
@override
String get exampleCoordinates => 'Example: 46.0569, 14.5058';
@override
String get drawingShared => 'Map Drawing';
@override
String get drawingHidden => 'Drawing hidden from map';
@override
String alreadyShared(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count already shared',
one: '1 already shared',
);
return '$_temp0';
}
@override
String newDrawingsShared(int count, String plural) {
return 'Shared $count new drawing$plural';
}
@override
String get shareDrawing => 'Share Drawing';
@@ -2008,12 +1417,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get forestRoads => 'Forest Roads';
@override
String get showCadastralParcels => 'Show Cadastral Parcels';
@override
String get showForestRoads => 'Show Forest Roads';
@override
String get wmsOverlays => 'WMS Overlays';
@@ -2117,10 +1520,6 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Failed to delete channel: $error';
}
@override
String get allChannelSlotsInUse =>
'All channel slots are in use (maximum 39 custom channels)';
@override
String get createChannel => 'Create Channel';
@@ -2160,21 +1559,6 @@ class AppLocalizationsEn extends AppLocalizations {
String get wizardConnectingFeature3 =>
'Works completely offline - no internet required';
@override
String get wizardSimpleModeTitle => 'Simple Mode';
@override
String get wizardSimpleModeDescription =>
'New to mesh networking? Enable Simple Mode for a streamlined interface with essential features only.';
@override
String get wizardSimpleModeFeature1 =>
'Beginner-friendly interface with core functions';
@override
String get wizardSimpleModeFeature2 =>
'Switch to Advanced Mode anytime in Settings';
@override
String get wizardChannelTitle => 'Channels';

View File

@@ -29,9 +29,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get disconnect => 'Desconectar';
@override
String get scanningForDevices => 'Buscando dispositivos...';
@override
String get noDevicesFound => 'No se encontraron dispositivos';
@@ -62,27 +59,14 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get failedToGetGpsLocation => 'Error al obtener la ubicación GPS';
@override
String advertisedAtLocation(String latitude, String longitude) {
return 'Anunciado en $latitude, $longitude';
}
@override
String failedToAdvertise(String error) {
return 'Error al anunciar: $error';
}
@override
String reconnecting(int attempt, int max) {
return 'Reconectando... ($attempt/$max)';
}
@override
String get cancelReconnection => 'Cancelar reconexión';
@override
String get mapManagement => 'Gestión de mapas';
@override
String get general => 'General';
@@ -129,13 +113,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get displayPacketActivity =>
'Mostrar indicadores de actividad de paquetes en la barra superior';
@override
String get simpleMode => 'Modo Simple';
@override
String get simpleModeDescription =>
'Ocultar información no esencial en mensajes y contactos';
@override
String get disableMap => 'Desactivar mapa';
@@ -149,75 +126,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get chooseLanguage => 'Elegir idioma';
@override
String get english => 'Inglés';
@override
String get slovenian => 'Esloveno';
@override
String get croatian => 'Croata';
@override
String get german => 'Alemán';
@override
String get spanish => 'Español';
@override
String get french => 'Francés';
@override
String get italian => 'Italiano';
@override
String get locationBroadcasting => 'Difusión de ubicación';
@override
String get autoLocationTracking => 'Seguimiento automático de ubicación';
@override
String get automaticallyBroadcastPosition =>
'Difundir automáticamente actualizaciones de posición';
@override
String get configureTracking => 'Configurar seguimiento';
@override
String get distanceAndTimeThresholds => 'Umbrales de distancia y tiempo';
@override
String get locationTrackingConfiguration =>
'Configuración de seguimiento de ubicación';
@override
String get configureWhenLocationBroadcasts =>
'Configurar cuándo se envían difusiones de ubicación a la red mesh';
@override
String get minimumDistance => 'Distancia mínima';
@override
String broadcastAfterMoving(String distance) {
return 'Difundir solo después de moverse $distance metros';
}
@override
String get maximumDistance => 'Distancia máxima';
@override
String alwaysBroadcastAfterMoving(String distance) {
return 'Siempre difundir después de moverse $distance metros';
}
@override
String get minimumTimeInterval => 'Intervalo de tiempo mínimo';
@override
String alwaysBroadcastEvery(String duration) {
return 'Siempre difundir cada $duration';
}
@override
String get save => 'Guardar';
@@ -253,12 +161,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get moreInfo => 'Más información';
@override
String get learnMoreAbout => 'Más información sobre MeshCore SAR';
@override
String get developer => 'Desarrollador';
@override
String get packageName => 'Nombre del paquete';
@@ -334,9 +236,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get refresh => 'Actualizar';
@override
String get sendDirectMessage => 'Enviar';
@override
String get resetPath => 'Restablecer ruta (Re-enrutar)';
@@ -459,21 +358,6 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Compartir $count dibujo$plural';
}
@override
String sentDrawingsToRoom(int count, String plural, String roomName) {
return 'Enviados $count dibujo$plural de mapa a $roomName';
}
@override
String sharedDrawingsToRoom(
int success,
int total,
String plural,
String roomName,
) {
return 'Compartidos $success/$total dibujo$plural con $roomName';
}
@override
String get showReceivedDrawings => 'Mostrar dibujos recibidos';
@@ -495,9 +379,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get clearAll => 'Borrar todo';
@override
String get noLocalDrawings => 'No hay dibujos locales para compartir';
@override
String get publicChannel => 'Canal público';
@@ -507,37 +388,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get storedPermanently => 'Almacenado permanentemente en la sala';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return '$count dibujo$plural de mapa enviado al Canal Público';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return '$success/$total dibujos compartidos al Canal Público';
}
@override
String get notConnectedToDevice => 'No conectado al dispositivo';
@override
String get directMessage => 'Mensaje directo';
@override
String directMessageSentTo(String contactName) {
return 'Mensaje directo enviado a $contactName';
}
@override
String failedToSend(String error) {
return 'Error al enviar: $error';
}
@override
String directMessageInfo(String contactName) {
return 'Este mensaje se enviará directamente a $contactName. También aparecerá en el feed de mensajes principal.';
}
@override
String get typeYourMessage => 'Escribe tu mensaje...';
@@ -567,9 +420,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get location => 'Ubicación';
@override
String get myLocation => 'Mi ubicación';
@override
String get fromMap => 'Desde el mapa';
@@ -631,9 +481,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get flood => 'Inundación';
@override
String get admin => 'Admin';
@override
String get loggedIn => 'Sesión iniciada';
@@ -643,29 +490,11 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get distance => 'Distancia';
@override
String pingingDirect(String name) {
return 'Haciendo ping a $name (directo vía ruta)...';
}
@override
String pingingFlood(String name) {
return 'Haciendo ping a $name (inundación - sin ruta)...';
}
@override
String directPingTimeout(String name) {
return 'Tiempo de espera de ping directo agotado - reintentando $name con inundación...';
}
@override
String pingSuccessful(String name, String fallback) {
return 'Ping exitoso a $name$fallback';
}
@override
String get viaFloodingFallback => ' (vía respaldo de inundación)';
@override
String pingFailed(String name) {
return 'Ping fallido a $name - no se recibió respuesta';
@@ -676,16 +505,6 @@ class AppLocalizationsEs extends AppLocalizations {
return '¿Estás seguro de que quieres eliminar \"$name\"?\n\nEsto eliminará el contacto tanto de la aplicación como del dispositivo de radio compañero.';
}
@override
String removingContact(String name) {
return 'Eliminando $name...';
}
@override
String contactRemoved(String name) {
return 'Contacto \"$name\" eliminado';
}
@override
String failedToRemoveContact(String error) {
return 'Error al eliminar contacto: $error';
@@ -730,11 +549,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get telemetry => 'Telemetría';
@override
String requestingTelemetry(String name) {
return 'Solicitando telemetría de $name...';
}
@override
String get voltage => 'Voltaje';
@@ -806,9 +620,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get showAll => 'Mostrar todo';
@override
String get nearbyContacts => 'Contactos cercanos';
@override
String get locationUnavailable => 'Ubicación no disponible';
@@ -849,176 +660,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get channels => 'Canales';
@override
String get cacheStatistics => 'Estadísticas de caché';
@override
String get totalTiles => 'Total de teselas';
@override
String get cacheSize => 'Tamaño de caché';
@override
String get storeName => 'Nombre del almacén';
@override
String get noCacheStatistics => 'No hay estadísticas de caché disponibles';
@override
String get downloadRegion => 'Descargar región';
@override
String get mapLayer => 'Capa de mapa';
@override
String get regionBounds => 'Límites de región';
@override
String get north => 'Norte';
@override
String get south => 'Sur';
@override
String get east => 'Este';
@override
String get west => 'Oeste';
@override
String get zoomLevels => 'Niveles de zoom';
@override
String minZoom(int zoom) {
return 'Mín: $zoom';
}
@override
String maxZoom(int zoom) {
return 'Máx: $zoom';
}
@override
String get downloadingDots => 'Descargando...';
@override
String get cancelDownload => 'Cancelar descarga';
@override
String get downloadRegionButton => 'Descargar región';
@override
String get downloadNote =>
'Nota: Las regiones grandes o niveles de zoom altos pueden tomar un tiempo y espacio de almacenamiento significativos.';
@override
String get cacheManagement => 'Gestión de caché';
@override
String get clearAllMaps => 'Borrar todos los mapas';
@override
String get clearMapsConfirmTitle => 'Borrar todos los mapas';
@override
String get clearMapsConfirmMessage =>
'¿Estás seguro de que quieres eliminar todos los mapas descargados? Esta acción no se puede deshacer.';
@override
String get mapDownloadCompleted => '¡Descarga de mapa completada!';
@override
String get cacheClearedSuccessfully => '¡Caché borrada exitosamente!';
@override
String get downloadCancelled => 'Descarga cancelada';
@override
String get startingDownload => 'Iniciando descarga...';
@override
String get downloadingMapTiles => 'Descargando teselas de mapa...';
@override
String get downloadCompletedSuccessfully =>
'¡Descarga completada exitosamente!';
@override
String get cancellingDownload => 'Cancelando descarga...';
@override
String errorLoadingStats(String error) {
return 'Error al cargar estadísticas: $error';
}
@override
String downloadFailed(String error) {
return 'Error en la descarga: $error';
}
@override
String cancelFailed(String error) {
return 'Error al cancelar: $error';
}
@override
String clearCacheFailed(String error) {
return 'Error al borrar caché: $error';
}
@override
String minZoomError(String error) {
return 'Zoom mín: $error';
}
@override
String maxZoomError(String error) {
return 'Zoom máx: $error';
}
@override
String get minZoomGreaterThanMax =>
'El zoom mínimo debe ser menor o igual al zoom máximo';
@override
String get selectMapLayer => 'Seleccionar capa de mapa';
@override
String get mapOptions => 'Opciones de mapa';
@override
String get showLegend => 'Mostrar leyenda';
@override
String get displayMarkerTypeCounts =>
'Mostrar recuentos de tipos de marcadores';
@override
String get rotateMapWithHeading => 'Rotar mapa con rumbo';
@override
String get mapFollowsDirection =>
'El mapa sigue tu dirección cuando te mueves';
@override
String get resetMapRotation => 'Restablecer rotación';
@override
String get resetMapRotationTooltip => 'Restablecer mapa al norte';
@override
String get showMapDebugInfo => 'Mostrar información de depuración del mapa';
@override
String get displayZoomLevelBounds => 'Mostrar nivel de zoom y límites';
@override
String get fullscreenMode => 'Modo de pantalla completa';
@override
String get hideUiFullMapView =>
'Ocultar todos los controles de IU para vista de mapa completo';
@override
String get openStreetMap => 'OpenStreetMap';
@@ -1037,12 +681,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get googleTerrain => 'Google Terreno';
@override
String get downloadVisibleArea => 'Descargar área visible';
@override
String get initializingMap => 'Inicializando mapa...';
@override
String get dragToPosition => 'Arrastrar a posición';
@@ -1058,51 +696,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get sarAlert => 'ALERTA SAR';
@override
String get messageSentToPublicChannel => 'Mensaje enviado al canal público';
@override
String get pleaseSelectRoomToSendSar =>
'Por favor, selecciona una sala para enviar marcador SAR';
@override
String failedToSendSarMarker(String error) {
return 'Error al enviar marcador SAR: $error';
}
@override
String sarMarkerSentTo(String roomName) {
return 'Marcador SAR enviado a $roomName';
}
@override
String get notConnectedCannotSync =>
'No conectado - no se pueden sincronizar mensajes';
@override
String syncedMessageCount(int count) {
return 'Sincronizados $count mensaje(s)';
}
@override
String get noNewMessages => 'No hay mensajes nuevos';
@override
String syncFailed(String error) {
return 'Error de sincronización: $error';
}
@override
String get failedToResendMessage => 'Error al reenviar mensaje';
@override
String get retryingMessage => 'Reintentando mensaje...';
@override
String retryFailed(String error) {
return 'Error de reintento: $error';
}
@override
String get textCopiedToClipboard => 'Texto copiado al portapapeles';
@@ -1114,9 +707,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get cannotReplyContactNotFound =>
'No se puede responder: contacto no encontrado';
@override
String get messageDeleted => 'Mensaje eliminado';
@override
String get copyText => 'Copiar texto';
@@ -1152,12 +742,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get sarLocationShare => 'Ubicación SAR';
@override
String get locationShared => 'Ubicación compartida';
@override
String get refreshedContacts => 'Contactos actualizados';
@override
String get justNow => 'Justo ahora';
@@ -1265,125 +849,22 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get you => '';
@override
String get offlineVectorMaps => 'Mapas vectoriales sin conexión';
@override
String get offlineVectorMapsDescription =>
'Importar y gestionar teselas de mapas vectoriales sin conexión (formato MBTiles) para usar sin conexión a internet';
@override
String get importMbtiles => 'Importar archivo MBTiles';
@override
String get importMbtilesNote =>
'Compatible con archivos MBTiles con teselas vectoriales (formato PBF/MVT). ¡Los extractos de Geofabrik funcionan muy bien!';
@override
String get noMbtilesFiles =>
'No se encontraron mapas vectoriales sin conexión';
@override
String get mbtilesImportedSuccessfully =>
'Archivo MBTiles importado exitosamente';
@override
String get failedToImportMbtiles => 'Error al importar archivo MBTiles';
@override
String get deleteMbtilesConfirmTitle => 'Eliminar mapa sin conexión';
@override
String deleteMbtilesConfirmMessage(String name) {
return '¿Estás seguro de que quieres eliminar \"$name\"? Esto eliminará permanentemente el mapa sin conexión.';
}
@override
String get mbtilesDeletedSuccessfully =>
'Mapa sin conexión eliminado exitosamente';
@override
String get failedToDeleteMbtiles => 'Error al eliminar mapa sin conexión';
@override
String get importExportCachedTiles => 'Importar/Exportar teselas en caché';
@override
String get importExportDescription =>
'Realice copias de seguridad, comparta y restaure teselas de mapas descargadas entre dispositivos';
@override
String get exportTilesToFile => 'Exportar teselas a archivo';
@override
String get importTilesFromFile => 'Importar teselas desde archivo';
@override
String get selectExportLocation => 'Seleccionar ubicación de exportación';
@override
String get selectImportFile => 'Seleccionar archivo de teselas';
@override
String get exportingTiles => 'Exportando teselas...';
@override
String get importingTiles => 'Importando teselas...';
@override
String exportSuccess(int count) {
return '$count teselas exportadas exitosamente';
}
@override
String importSuccess(int count) {
return '$count almacenes importados exitosamente';
}
@override
String exportFailed(String error) {
return 'Export failed: $error';
return 'Error al exportar: $error';
}
@override
String importFailed(String error) {
return 'Import failed: $error';
return 'Error al importar: $error';
}
@override
String get exportNote =>
'Crea un archivo comprimido (.fmtc) que se puede compartir e importar en otros dispositivos.';
@override
String get importNote =>
'Importa teselas de mapa desde un archivo previamente exportado. Las teselas se fusionarán con la caché existente.';
@override
String get noTilesToExport => 'No hay teselas para exportar';
@override
String archiveContainsStores(int count) {
return 'El archivo contiene $count almacenes';
}
@override
String get vectorTiles => 'Teselas vectoriales';
@override
String get schema => 'Esquema';
@override
String get unknown => 'Desconocido';
@override
String get bounds => 'Límites';
@override
String get onlineLayers => 'Capas en línea';
@override
String get offlineLayers => 'Capas sin conexión';
@override
String get locationTrail => 'Rastro de ubicación';
@@ -1422,47 +903,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get trailControls => 'Controles del rastro';
@override
String get exportTrailToGpx => 'Exportar rastro a GPX';
@override
String get importTrailFromGpx => 'Importar rastro desde GPX';
@override
String get trailExportedSuccessfully => '¡Rastro exportado exitosamente!';
@override
String get failedToExportTrail => 'Error al exportar el rastro';
@override
String failedToImportTrail(String error) {
return 'Error al importar el rastro: $error';
}
@override
String get importTrail => 'Importar rastro';
@override
String importTrailQuestion(int pointCount) {
return '¿Importar rastro con $pointCount puntos?\n\nPuede reemplazar su rastro actual o verlo junto a él.';
}
@override
String get viewAlongside => 'Ver junto';
@override
String get replaceCurrent => 'Reemplazar actual';
@override
String trailImported(int pointCount) {
return '¡Rastro importado! ($pointCount puntos)';
}
@override
String trailReplaced(int pointCount) {
return '¡Rastro reemplazado! ($pointCount puntos)';
}
@override
String get contactTrails => 'Rastros de contactos';
@@ -1583,7 +1023,7 @@ class AppLocalizationsEs extends AppLocalizations {
String get templateName => 'Nombre de plantilla';
@override
String get templateNameHint => 'e.g. Found Person';
String get templateNameHint => 'p. ej., Persona encontrada';
@override
String get templateEmoji => 'Emoji';
@@ -1595,16 +1035,16 @@ class AppLocalizationsEs extends AppLocalizations {
String get nameRequired => 'Se requiere nombre';
@override
String get templateDescription => 'Description (Optional)';
String get templateDescription => 'Descripcion (opcional)';
@override
String get templateDescriptionHint => 'Add additional context...';
String get templateDescriptionHint => 'Anade contexto adicional...';
@override
String get templateColor => 'Color';
@override
String get previewFormat => 'Preview (SAR Message Format)';
String get previewFormat => 'Vista previa (formato de mensaje SAR)';
@override
String get importFromClipboard => 'Importar';
@@ -1614,26 +1054,26 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String deleteTemplateConfirmation(String name) {
return 'Delete template \'$name\'?';
return '¿Eliminar la plantilla \'$name\'?';
}
@override
String get templateAdded => 'Template added';
String get templateAdded => 'Plantilla anadida';
@override
String get templateUpdated => 'Template updated';
String get templateUpdated => 'Plantilla actualizada';
@override
String get templateDeleted => 'Template deleted';
String get templateDeleted => 'Plantilla eliminada';
@override
String templatesImported(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Imported $count templates',
one: 'Imported 1 template',
zero: 'No templates imported',
other: 'Se importaron $count plantillas',
one: 'Se importo 1 plantilla',
zero: 'No se importaron plantillas',
);
return '$_temp0';
}
@@ -1643,8 +1083,8 @@ class AppLocalizationsEs extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Exported $count templates to clipboard',
one: 'Exported 1 template to clipboard',
other: 'Se exportaron $count plantillas al portapapeles',
one: 'Se exporto 1 plantilla al portapapeles',
);
return '$_temp0';
}
@@ -1664,10 +1104,10 @@ class AppLocalizationsEs extends AppLocalizations {
'Plantillas restablecidas a valores predeterminados';
@override
String get noTemplates => 'No templates available';
String get noTemplates => 'No hay plantillas disponibles';
@override
String get tapAddToCreate => 'Tap + to create your first template';
String get tapAddToCreate => 'Toca + para crear tu primera plantilla';
@override
String get ok => 'OK';
@@ -1735,20 +1175,12 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get noRoomsFound => 'No se encontraron salas';
@override
String get noContactsOrRoomsAvailable =>
'No hay contactos o salas disponibles';
@override
String get noRecipientsAvailable => 'No hay destinatarios disponibles';
@override
String get noChannelsFound => 'No se encontraron canales';
@override
String get messagesWillBeSentToPublicChannel =>
'Los mensajes se enviarán al canal público';
@override
String get newMessage => 'Nuevo mensaje';
@@ -1936,10 +1368,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get rectangleDrawing => 'Rectángulo';
@override
String get coordinatesCopiedToClipboard =>
'Coordenadas copiadas al portapapeles';
@override
String get manualCoordinates => 'Coordenadas Manuales';
@@ -1952,31 +1380,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get longitudeLabel => 'Longitud';
@override
String get invalidLatitude => 'Latitud inválida (-90 a 90)';
@override
String get invalidLongitude => 'Longitud inválida (-180 a 180)';
@override
String get exampleCoordinates => 'Ejemplo: 46.0569, 14.5058';
@override
String get drawingShared => 'Dibujo del Mapa';
@override
String get drawingHidden => 'Dibujo ocultado del mapa';
@override
String alreadyShared(int count) {
return '$count ya compartido';
}
@override
String newDrawingsShared(int count, String plural) {
return '$count nuevo(s) dibujo(s) compartido(s)';
}
@override
String get shareDrawing => 'Compartir Dibujo';
@@ -2033,12 +1439,6 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get forestRoads => 'Caminos Forestales';
@override
String get showCadastralParcels => 'Mostrar parcelas catastrales';
@override
String get showForestRoads => 'Mostrar caminos forestales';
@override
String get wmsOverlays => 'Superposiciones WMS';
@@ -2143,10 +1543,6 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Error al eliminar el canal: $error';
}
@override
String get allChannelSlotsInUse =>
'Todos los espacios de canales están en uso (máximo 39 canales personalizados)';
@override
String get createChannel => 'Crear Canal';
@@ -2187,21 +1583,6 @@ class AppLocalizationsEs extends AppLocalizations {
String get wizardConnectingFeature3 =>
'Funciona completamente sin conexión - no se requiere internet';
@override
String get wizardSimpleModeTitle => 'Modo Simple';
@override
String get wizardSimpleModeDescription =>
'¿Nuevo en redes en malla? Habilite el modo simple para una interfaz optimizada con solo funciones esenciales.';
@override
String get wizardSimpleModeFeature1 =>
'Interfaz amigable para principiantes con funciones principales';
@override
String get wizardSimpleModeFeature2 =>
'Cambie al modo avanzado en cualquier momento en Configuración';
@override
String get wizardChannelTitle => 'Canales';

View File

@@ -29,9 +29,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get disconnect => 'Déconnecter';
@override
String get scanningForDevices => 'Recherche d\'appareils...';
@override
String get noDevicesFound => 'Aucun appareil trouvé';
@@ -63,27 +60,14 @@ class AppLocalizationsFr extends AppLocalizations {
String get failedToGetGpsLocation =>
'Échec de l\'obtention de la position GPS';
@override
String advertisedAtLocation(String latitude, String longitude) {
return 'Annoncé à $latitude, $longitude';
}
@override
String failedToAdvertise(String error) {
return 'Échec de l\'annonce : $error';
}
@override
String reconnecting(int attempt, int max) {
return 'Reconnexion... ($attempt/$max)';
}
@override
String get cancelReconnection => 'Annuler la reconnexion';
@override
String get mapManagement => 'Gestion des cartes';
@override
String get general => 'Général';
@@ -130,13 +114,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get displayPacketActivity =>
'Afficher les indicateurs d\'activité des paquets dans la barre supérieure';
@override
String get simpleMode => 'Mode Simple';
@override
String get simpleModeDescription =>
'Masquer les informations non essentielles dans les messages et les contacts';
@override
String get disableMap => 'Désactiver la carte';
@@ -150,75 +127,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get chooseLanguage => 'Choisir la langue';
@override
String get english => 'Anglais';
@override
String get slovenian => 'Slovène';
@override
String get croatian => 'Croate';
@override
String get german => 'Allemand';
@override
String get spanish => 'Espagnol';
@override
String get french => 'Français';
@override
String get italian => 'Italien';
@override
String get locationBroadcasting => 'Diffusion de position';
@override
String get autoLocationTracking => 'Suivi automatique de position';
@override
String get automaticallyBroadcastPosition =>
'Diffuser automatiquement les mises à jour de position';
@override
String get configureTracking => 'Configurer le suivi';
@override
String get distanceAndTimeThresholds => 'Seuils de distance et de temps';
@override
String get locationTrackingConfiguration =>
'Configuration du suivi de position';
@override
String get configureWhenLocationBroadcasts =>
'Configurer quand les diffusions de position sont envoyées au réseau maillé';
@override
String get minimumDistance => 'Distance minimale';
@override
String broadcastAfterMoving(String distance) {
return 'Diffuser uniquement après un déplacement de $distance mètres';
}
@override
String get maximumDistance => 'Distance maximale';
@override
String alwaysBroadcastAfterMoving(String distance) {
return 'Toujours diffuser après un déplacement de $distance mètres';
}
@override
String get minimumTimeInterval => 'Intervalle de temps minimal';
@override
String alwaysBroadcastEvery(String duration) {
return 'Toujours diffuser toutes les $duration';
}
@override
String get save => 'Enregistrer';
@@ -254,12 +162,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get moreInfo => 'Plus d\'infos';
@override
String get learnMoreAbout => 'En savoir plus sur MeshCore SAR';
@override
String get developer => 'Développeur';
@override
String get packageName => 'Nom du package';
@@ -336,9 +238,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get refresh => 'Actualiser';
@override
String get sendDirectMessage => 'Envoyer';
@override
String get resetPath => 'Réinitialiser le chemin (Re-router)';
@@ -459,21 +358,6 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Partager $count dessin$plural';
}
@override
String sentDrawingsToRoom(int count, String plural, String roomName) {
return '$count dessin$plural de carte envoyé$plural à $roomName';
}
@override
String sharedDrawingsToRoom(
int success,
int total,
String plural,
String roomName,
) {
return '$success/$total dessin$plural partagé$plural avec $roomName';
}
@override
String get showReceivedDrawings => 'Afficher les dessins reçus';
@@ -495,9 +379,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get clearAll => 'Tout effacer';
@override
String get noLocalDrawings => 'Aucun dessin local à partager';
@override
String get publicChannel => 'Canal public';
@@ -508,37 +389,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get storedPermanently => 'Stocké de manière permanente dans le salon';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return '$count dessin$plural de carte envoyé au Canal Public';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return '$success/$total dessins partagés sur le Canal Public';
}
@override
String get notConnectedToDevice => 'Non connecté à l\'appareil';
@override
String get directMessage => 'Message direct';
@override
String directMessageSentTo(String contactName) {
return 'Message direct envoyé à $contactName';
}
@override
String failedToSend(String error) {
return 'Échec de l\'envoi : $error';
}
@override
String directMessageInfo(String contactName) {
return 'Ce message sera envoyé directement à $contactName. Il apparaîtra également dans le fil de messages principal.';
}
@override
String get typeYourMessage => 'Saisissez votre message...';
@@ -568,9 +421,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get location => 'Position';
@override
String get myLocation => 'Ma position';
@override
String get fromMap => 'Depuis la carte';
@@ -633,9 +483,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get flood => 'Inondation';
@override
String get admin => 'Admin';
@override
String get loggedIn => 'Connecté';
@@ -645,29 +492,11 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get distance => 'Distance';
@override
String pingingDirect(String name) {
return 'Ping de $name (direct via chemin)...';
}
@override
String pingingFlood(String name) {
return 'Ping de $name (inondation - pas de chemin)...';
}
@override
String directPingTimeout(String name) {
return 'Délai d\'attente du ping direct - nouvelle tentative de $name avec inondation...';
}
@override
String pingSuccessful(String name, String fallback) {
return 'Ping réussi vers $name$fallback';
}
@override
String get viaFloodingFallback => ' (via repli par inondation)';
@override
String pingFailed(String name) {
return 'Échec du ping vers $name - aucune réponse reçue';
@@ -678,16 +507,6 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Êtes-vous sûr de vouloir supprimer \"$name\" ?\n\nCela supprimera le contact de l\'application et de l\'appareil radio compagnon.';
}
@override
String removingContact(String name) {
return 'Suppression de $name...';
}
@override
String contactRemoved(String name) {
return 'Contact \"$name\" supprimé';
}
@override
String failedToRemoveContact(String error) {
return 'Échec de la suppression du contact : $error';
@@ -732,11 +551,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get telemetry => 'Télémétrie';
@override
String requestingTelemetry(String name) {
return 'Demande de télémétrie à $name...';
}
@override
String get voltage => 'Tension';
@@ -808,9 +622,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get showAll => 'Tout afficher';
@override
String get nearbyContacts => 'Contacts à proximité';
@override
String get locationUnavailable => 'Position non disponible';
@@ -851,177 +662,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get channels => 'Canaux';
@override
String get cacheStatistics => 'Statistiques du cache';
@override
String get totalTiles => 'Total de tuiles';
@override
String get cacheSize => 'Taille du cache';
@override
String get storeName => 'Nom du magasin';
@override
String get noCacheStatistics => 'Aucune statistique de cache disponible';
@override
String get downloadRegion => 'Télécharger une région';
@override
String get mapLayer => 'Couche de carte';
@override
String get regionBounds => 'Limites de la région';
@override
String get north => 'Nord';
@override
String get south => 'Sud';
@override
String get east => 'Est';
@override
String get west => 'Ouest';
@override
String get zoomLevels => 'Niveaux de zoom';
@override
String minZoom(int zoom) {
return 'Min : $zoom';
}
@override
String maxZoom(int zoom) {
return 'Max : $zoom';
}
@override
String get downloadingDots => 'Téléchargement...';
@override
String get cancelDownload => 'Annuler le téléchargement';
@override
String get downloadRegionButton => 'Télécharger la région';
@override
String get downloadNote =>
'Remarque : Les grandes régions ou les niveaux de zoom élevés peuvent nécessiter beaucoup de temps et d\'espace de stockage.';
@override
String get cacheManagement => 'Gestion du cache';
@override
String get clearAllMaps => 'Effacer toutes les cartes';
@override
String get clearMapsConfirmTitle => 'Effacer toutes les cartes';
@override
String get clearMapsConfirmMessage =>
'Êtes-vous sûr de vouloir supprimer toutes les cartes téléchargées ? Cette action ne peut pas être annulée.';
@override
String get mapDownloadCompleted => 'Téléchargement de la carte terminé !';
@override
String get cacheClearedSuccessfully => 'Cache effacé avec succès !';
@override
String get downloadCancelled => 'Téléchargement annulé';
@override
String get startingDownload => 'Démarrage du téléchargement...';
@override
String get downloadingMapTiles => 'Téléchargement des tuiles de carte...';
@override
String get downloadCompletedSuccessfully =>
'Téléchargement terminé avec succès !';
@override
String get cancellingDownload => 'Annulation du téléchargement...';
@override
String errorLoadingStats(String error) {
return 'Erreur de chargement des statistiques : $error';
}
@override
String downloadFailed(String error) {
return 'Échec du téléchargement : $error';
}
@override
String cancelFailed(String error) {
return 'Échec de l\'annulation : $error';
}
@override
String clearCacheFailed(String error) {
return 'Échec de l\'effacement du cache : $error';
}
@override
String minZoomError(String error) {
return 'Zoom min : $error';
}
@override
String maxZoomError(String error) {
return 'Zoom max : $error';
}
@override
String get minZoomGreaterThanMax =>
'Le zoom minimal doit être inférieur ou égal au zoom maximal';
@override
String get selectMapLayer => 'Sélectionner la couche de carte';
@override
String get mapOptions => 'Options de carte';
@override
String get showLegend => 'Afficher la légende';
@override
String get displayMarkerTypeCounts =>
'Afficher les décomptes des types de marqueurs';
@override
String get rotateMapWithHeading => 'Faire pivoter la carte avec le cap';
@override
String get mapFollowsDirection =>
'La carte suit votre direction lorsque vous vous déplacez';
@override
String get resetMapRotation => 'Réinitialiser la rotation';
@override
String get resetMapRotationTooltip => 'Réinitialiser la carte vers le nord';
@override
String get showMapDebugInfo => 'Afficher les infos de débogage de la carte';
@override
String get displayZoomLevelBounds =>
'Afficher le niveau de zoom et les limites';
@override
String get fullscreenMode => 'Mode plein écran';
@override
String get hideUiFullMapView =>
'Masquer tous les contrôles d\'interface pour une vue de carte complète';
@override
String get openStreetMap => 'OpenStreetMap';
@@ -1040,12 +683,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get googleTerrain => 'Google Terrain';
@override
String get downloadVisibleArea => 'Télécharger la zone visible';
@override
String get initializingMap => 'Initialisation de la carte...';
@override
String get dragToPosition => 'Faire glisser vers la position';
@@ -1061,51 +698,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get sarAlert => 'ALERTE SAR';
@override
String get messageSentToPublicChannel => 'Message envoyé au canal public';
@override
String get pleaseSelectRoomToSendSar =>
'Veuillez sélectionner un salon pour envoyer le marqueur SAR';
@override
String failedToSendSarMarker(String error) {
return 'Échec de l\'envoi du marqueur SAR : $error';
}
@override
String sarMarkerSentTo(String roomName) {
return 'Marqueur SAR envoyé à $roomName';
}
@override
String get notConnectedCannotSync =>
'Non connecté - impossible de synchroniser les messages';
@override
String syncedMessageCount(int count) {
return 'Synchronisé $count message(s)';
}
@override
String get noNewMessages => 'Aucun nouveau message';
@override
String syncFailed(String error) {
return 'Échec de la synchronisation : $error';
}
@override
String get failedToResendMessage => 'Échec du renvoi du message';
@override
String get retryingMessage => 'Nouvelle tentative de message...';
@override
String retryFailed(String error) {
return 'Échec de la nouvelle tentative : $error';
}
@override
String get textCopiedToClipboard => 'Texte copié dans le presse-papiers';
@@ -1117,9 +709,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get cannotReplyContactNotFound =>
'Impossible de répondre : contact non trouvé';
@override
String get messageDeleted => 'Message supprimé';
@override
String get copyText => 'Copier le texte';
@@ -1155,12 +744,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get sarLocationShare => 'Position SAR';
@override
String get locationShared => 'Position partagée';
@override
String get refreshedContacts => 'Contacts actualisés';
@override
String get justNow => 'À l\'instant';
@@ -1268,127 +851,22 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get you => 'Vous';
@override
String get offlineVectorMaps => 'Cartes vectorielles hors ligne';
@override
String get offlineVectorMapsDescription =>
'Importer et gérer les tuiles de cartes vectorielles hors ligne (format MBTiles) pour une utilisation sans connexion Internet';
@override
String get importMbtiles => 'Importer un fichier MBTiles';
@override
String get importMbtilesNote =>
'Prend en charge les fichiers MBTiles avec tuiles vectorielles (format PBF/MVT). Les extraits Geofabrik fonctionnent très bien !';
@override
String get noMbtilesFiles => 'Aucune carte vectorielle hors ligne trouvée';
@override
String get mbtilesImportedSuccessfully =>
'Fichier MBTiles importé avec succès';
@override
String get failedToImportMbtiles =>
'Échec de l\'importation du fichier MBTiles';
@override
String get deleteMbtilesConfirmTitle => 'Supprimer la carte hors ligne';
@override
String deleteMbtilesConfirmMessage(String name) {
return 'Êtes-vous sûr de vouloir supprimer \"$name\" ? Cela supprimera définitivement la carte hors ligne.';
}
@override
String get mbtilesDeletedSuccessfully =>
'Carte hors ligne supprimée avec succès';
@override
String get failedToDeleteMbtiles =>
'Échec de la suppression de la carte hors ligne';
@override
String get importExportCachedTiles => 'Importer/Exporter les tuiles en cache';
@override
String get importExportDescription =>
'Sauvegarder, partager et restaurer les tuiles de carte téléchargées entre appareils';
@override
String get exportTilesToFile => 'Exporter les tuiles vers fichier';
@override
String get importTilesFromFile => 'Importer les tuiles depuis fichier';
@override
String get selectExportLocation =>
'Sélectionner l\'emplacement d\'exportation';
@override
String get selectImportFile => 'Sélectionner l\'archive de tuiles';
@override
String get exportingTiles => 'Exportation des tuiles...';
@override
String get importingTiles => 'Importation des tuiles...';
@override
String exportSuccess(int count) {
return '$count tuiles exportées avec succès';
}
@override
String importSuccess(int count) {
return '$count magasins importés avec succès';
}
@override
String exportFailed(String error) {
return 'Export failed: $error';
return 'Echec de l\'exportation : $error';
}
@override
String importFailed(String error) {
return 'Import failed: $error';
return 'Echec de l\'importation : $error';
}
@override
String get exportNote =>
'Crée un fichier d\'archive compressé (.fmtc) qui peut être partagé et importé sur d\'autres appareils.';
@override
String get importNote =>
'Importe les tuiles de carte depuis un fichier d\'archive précédemment exporté. Les tuiles seront fusionnées avec le cache existant.';
@override
String get noTilesToExport => 'Aucune tuile à exporter';
@override
String archiveContainsStores(int count) {
return 'L\'archive contient $count magasins';
}
@override
String get vectorTiles => 'Tuiles vectorielles';
@override
String get schema => 'Schéma';
@override
String get unknown => 'Inconnu';
@override
String get bounds => 'Limites';
@override
String get onlineLayers => 'Couches en ligne';
@override
String get offlineLayers => 'Couches hors ligne';
@override
String get locationTrail => 'Trace de déplacement';
@@ -1428,47 +906,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get trailControls => 'Contrôles de la trace';
@override
String get exportTrailToGpx => 'Exporter la trace vers GPX';
@override
String get importTrailFromGpx => 'Importer la trace depuis GPX';
@override
String get trailExportedSuccessfully => 'Trace exportée avec succès!';
@override
String get failedToExportTrail => 'Échec de l\'exportation de la trace';
@override
String failedToImportTrail(String error) {
return 'Échec de l\'importation de la trace: $error';
}
@override
String get importTrail => 'Importer la trace';
@override
String importTrailQuestion(int pointCount) {
return 'Importer la trace avec $pointCount points?\n\nVous pouvez remplacer votre trace actuelle ou l\'afficher à côté.';
}
@override
String get viewAlongside => 'Afficher à côté';
@override
String get replaceCurrent => 'Remplacer l\'actuel';
@override
String trailImported(int pointCount) {
return 'Trace importée! ($pointCount points)';
}
@override
String trailReplaced(int pointCount) {
return 'Trace remplacée! ($pointCount points)';
}
@override
String get contactTrails => 'Traces des contacts';
@@ -1589,7 +1026,7 @@ class AppLocalizationsFr extends AppLocalizations {
String get templateName => 'Nom du modèle';
@override
String get templateNameHint => 'e.g. Found Person';
String get templateNameHint => 'p. ex. Personne trouvee';
@override
String get templateEmoji => 'Emoji';
@@ -1601,16 +1038,16 @@ class AppLocalizationsFr extends AppLocalizations {
String get nameRequired => 'Nom est requis';
@override
String get templateDescription => 'Description (Optional)';
String get templateDescription => 'Description (facultative)';
@override
String get templateDescriptionHint => 'Add additional context...';
String get templateDescriptionHint => 'Ajoutez un contexte supplementaire...';
@override
String get templateColor => 'Color';
String get templateColor => 'Couleur';
@override
String get previewFormat => 'Preview (SAR Message Format)';
String get previewFormat => 'Apercu (format de message SAR)';
@override
String get importFromClipboard => 'Importer';
@@ -1620,26 +1057,26 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String deleteTemplateConfirmation(String name) {
return 'Delete template \'$name\'?';
return 'Supprimer le modele \'$name\' ?';
}
@override
String get templateAdded => 'Template added';
String get templateAdded => 'Modele ajoute';
@override
String get templateUpdated => 'Template updated';
String get templateUpdated => 'Modele mis a jour';
@override
String get templateDeleted => 'Template deleted';
String get templateDeleted => 'Modele supprime';
@override
String templatesImported(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Imported $count templates',
one: 'Imported 1 template',
zero: 'No templates imported',
other: '$count modeles importes',
one: '1 modele importe',
zero: 'Aucun modele importe',
);
return '$_temp0';
}
@@ -1649,8 +1086,8 @@ class AppLocalizationsFr extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Exported $count templates to clipboard',
one: 'Exported 1 template to clipboard',
other: '$count modeles exportes vers le presse-papiers',
one: '1 modele exporte vers le presse-papiers',
);
return '$_temp0';
}
@@ -1669,10 +1106,10 @@ class AppLocalizationsFr extends AppLocalizations {
String get resetComplete => 'Modèles réinitialisés aux valeurs par défaut';
@override
String get noTemplates => 'No templates available';
String get noTemplates => 'Aucun modele disponible';
@override
String get tapAddToCreate => 'Tap + to create your first template';
String get tapAddToCreate => 'Appuyez sur + pour creer votre premier modele';
@override
String get ok => 'OK';
@@ -1741,19 +1178,12 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get noRoomsFound => 'Aucune salle trouvée';
@override
String get noContactsOrRoomsAvailable => 'Aucun contact ou salle disponible';
@override
String get noRecipientsAvailable => 'Aucun destinataire disponible';
@override
String get noChannelsFound => 'Aucun canal trouvé';
@override
String get messagesWillBeSentToPublicChannel =>
'Les messages seront envoyés au canal public';
@override
String get newMessage => 'Nouveau message';
@@ -1940,10 +1370,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get rectangleDrawing => 'Rectangle';
@override
String get coordinatesCopiedToClipboard =>
'Coordonnées copiées dans le presse-papiers';
@override
String get manualCoordinates => 'Coordonnées Manuelles';
@@ -1956,31 +1382,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get longitudeLabel => 'Longitude';
@override
String get invalidLatitude => 'Latitude invalide (-90 à 90)';
@override
String get invalidLongitude => 'Longitude invalide (-180 à 180)';
@override
String get exampleCoordinates => 'Exemple: 46.0569, 14.5058';
@override
String get drawingShared => 'Dessin de Carte';
@override
String get drawingHidden => 'Dessin masqué de la carte';
@override
String alreadyShared(int count) {
return '$count déjà partagé';
}
@override
String newDrawingsShared(int count, String plural) {
return '$count nouveau(x) dessin(s) partagé(s)';
}
@override
String get shareDrawing => 'Partager le Dessin';
@@ -2037,12 +1441,6 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get forestRoads => 'Chemins Forestiers';
@override
String get showCadastralParcels => 'Afficher les parcelles cadastrales';
@override
String get showForestRoads => 'Afficher les chemins forestiers';
@override
String get wmsOverlays => 'Superpositions WMS';
@@ -2148,10 +1546,6 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Échec de la suppression du canal : $error';
}
@override
String get allChannelSlotsInUse =>
'Tous les emplacements de canaux sont utilisés (maximum 39 canaux personnalisés)';
@override
String get createChannel => 'Créer un Canal';
@@ -2193,21 +1587,6 @@ class AppLocalizationsFr extends AppLocalizations {
String get wizardConnectingFeature3 =>
'Fonctionne entièrement hors ligne - aucun internet requis';
@override
String get wizardSimpleModeTitle => 'Mode Simple';
@override
String get wizardSimpleModeDescription =>
'Nouveau dans les réseaux maillés ? Activez le mode simple pour une interface simplifiée avec seulement les fonctions essentielles.';
@override
String get wizardSimpleModeFeature1 =>
'Interface conviviale pour débutants avec les fonctions principales';
@override
String get wizardSimpleModeFeature2 =>
'Passer en mode avancé à tout moment dans les paramètres';
@override
String get wizardChannelTitle => 'Canaux';

View File

@@ -29,9 +29,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get disconnect => 'Prekini';
@override
String get scanningForDevices => 'Skeniranje uređaja...';
@override
String get noDevicesFound => 'Nisu pronađeni uređaji';
@@ -62,27 +59,14 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get failedToGetGpsLocation => 'Neuspjelo dobivanje GPS lokacije';
@override
String advertisedAtLocation(String latitude, String longitude) {
return 'Objavljeno na $latitude, $longitude';
}
@override
String failedToAdvertise(String error) {
return 'Neuspjela objava: $error';
}
@override
String reconnecting(int attempt, int max) {
return 'Ponovno povezivanje... ($attempt/$max)';
}
@override
String get cancelReconnection => 'Otkaži ponovno povezivanje';
@override
String get mapManagement => 'Upravljanje kartom';
@override
String get general => 'Općenito';
@@ -129,13 +113,6 @@ class AppLocalizationsHr extends AppLocalizations {
String get displayPacketActivity =>
'Prikaži indikatore aktivnosti paketa u gornjoj traci';
@override
String get simpleMode => 'Jednostavni način';
@override
String get simpleModeDescription =>
'Sakrij nevažne informacije u porukama i kontaktima';
@override
String get disableMap => 'Onemogući kartu';
@@ -148,74 +125,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get chooseLanguage => 'Odaberite jezik';
@override
String get english => 'Engleski';
@override
String get slovenian => 'Slovenski';
@override
String get croatian => 'Hrvatski';
@override
String get german => 'Njemački';
@override
String get spanish => 'Španjolski';
@override
String get french => 'Francuski';
@override
String get italian => 'Talijanski';
@override
String get locationBroadcasting => 'Emitiranje lokacije';
@override
String get autoLocationTracking => 'Automatsko praćenje lokacije';
@override
String get automaticallyBroadcastPosition =>
'Automatski emitiraj ažuriranja pozicije';
@override
String get configureTracking => 'Konfiguriraj praćenje';
@override
String get distanceAndTimeThresholds => 'Pragovi udaljenosti i vremena';
@override
String get locationTrackingConfiguration => 'Konfiguracija praćenja lokacije';
@override
String get configureWhenLocationBroadcasts =>
'Konfigurirajte kada se emitiranja lokacije šalju u mesh mrežu';
@override
String get minimumDistance => 'Minimalna udaljenost';
@override
String broadcastAfterMoving(String distance) {
return 'Emitiraj tek nakon pomicanja $distance metara';
}
@override
String get maximumDistance => 'Maksimalna udaljenost';
@override
String alwaysBroadcastAfterMoving(String distance) {
return 'Uvijek emitiraj nakon pomicanja $distance metara';
}
@override
String get minimumTimeInterval => 'Minimalni vremenski interval';
@override
String alwaysBroadcastEvery(String duration) {
return 'Uvijek emitiraj svakih $duration';
}
@override
String get save => 'Spremi';
@@ -251,12 +160,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get moreInfo => 'Više informacija';
@override
String get learnMoreAbout => 'Saznajte više o MeshCore SAR-u';
@override
String get developer => 'Programer';
@override
String get packageName => 'Ime paketa';
@@ -332,9 +235,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get refresh => 'Osvježi';
@override
String get sendDirectMessage => 'Pošalji';
@override
String get resetPath => 'Resetiraj put (preusmjeri)';
@@ -453,21 +353,6 @@ class AppLocalizationsHr extends AppLocalizations {
return 'Podijeli $count crtež$plural';
}
@override
String sentDrawingsToRoom(int count, String plural, String roomName) {
return 'Poslano $count crtež$plural karte u $roomName';
}
@override
String sharedDrawingsToRoom(
int success,
int total,
String plural,
String roomName,
) {
return 'Podijeljeno $success/$total crtež$plural u $roomName';
}
@override
String get showReceivedDrawings => 'Prikaži primljene crteže';
@@ -489,9 +374,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get clearAll => 'Očisti sve';
@override
String get noLocalDrawings => 'Nema lokalnih crteža za dijeljenje';
@override
String get publicChannel => 'Javni kanal';
@@ -501,37 +383,9 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get storedPermanently => 'Trajno pohranjeno u sobi';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return 'Poslano $count crtež$plural na javni kanal';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return 'Podijeljeno $success/$total crteža na javni kanal';
}
@override
String get notConnectedToDevice => 'Nije povezano s uređajem';
@override
String get directMessage => 'Izravna poruka';
@override
String directMessageSentTo(String contactName) {
return 'Izravna poruka poslana $contactName';
}
@override
String failedToSend(String error) {
return 'Neuspjelo slanje: $error';
}
@override
String directMessageInfo(String contactName) {
return 'Ova poruka će biti poslana izravno $contactName. Također će se prikazati u glavnom feedu poruka.';
}
@override
String get typeYourMessage => 'Upišite svoju poruku...';
@@ -561,9 +415,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get location => 'Lokacija';
@override
String get myLocation => 'Moja lokacija';
@override
String get fromMap => 'S karte';
@@ -625,9 +476,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get flood => 'Preplavljanje';
@override
String get admin => 'Administrator';
@override
String get loggedIn => 'Prijavljen';
@@ -637,29 +485,11 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get distance => 'Udaljenost';
@override
String pingingDirect(String name) {
return 'Pingiranje $name (izravno putem puta)...';
}
@override
String pingingFlood(String name) {
return 'Pingiranje $name (preplavljanje - nema puta)...';
}
@override
String directPingTimeout(String name) {
return 'Istek izravnog pinga - ponovni pokušaj $name s preplavljanjem...';
}
@override
String pingSuccessful(String name, String fallback) {
return 'Ping uspješan prema $name$fallback';
}
@override
String get viaFloodingFallback => ' (putem rezervnog preplavljanja)';
@override
String pingFailed(String name) {
return 'Ping neuspješan prema $name - nije primljen odgovor';
@@ -670,16 +500,6 @@ class AppLocalizationsHr extends AppLocalizations {
return 'Jeste li sigurni da želite izbrisati \"$name\"?\n\nOvo će ukloniti kontakt iz aplikacije i pratećeg radio uređaja.';
}
@override
String removingContact(String name) {
return 'Uklanjanje $name...';
}
@override
String contactRemoved(String name) {
return 'Kontakt \"$name\" uklonjen';
}
@override
String failedToRemoveContact(String error) {
return 'Neuspjelo uklanjanje kontakta: $error';
@@ -724,11 +544,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get telemetry => 'Telemetrija';
@override
String requestingTelemetry(String name) {
return 'Zahtijevanje telemetrije od $name...';
}
@override
String get voltage => 'Napon';
@@ -800,9 +615,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get showAll => 'Prikaži sve';
@override
String get nearbyContacts => 'Obližnji kontakti';
@override
String get locationUnavailable => 'Lokacija nije dostupna';
@@ -843,174 +655,9 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get channels => 'Kanali';
@override
String get cacheStatistics => 'Statistika predmemorije';
@override
String get totalTiles => 'Ukupno pločica';
@override
String get cacheSize => 'Veličina predmemorije';
@override
String get storeName => 'Naziv spremišta';
@override
String get noCacheStatistics => 'Statistika predmemorije nije dostupna';
@override
String get downloadRegion => 'Preuzmi regiju';
@override
String get mapLayer => 'Sloj karte';
@override
String get regionBounds => 'Granice regije';
@override
String get north => 'Sjever';
@override
String get south => 'Jug';
@override
String get east => 'Istok';
@override
String get west => 'Zapad';
@override
String get zoomLevels => 'Razine zumiranja';
@override
String minZoom(int zoom) {
return 'Min: $zoom';
}
@override
String maxZoom(int zoom) {
return 'Maks: $zoom';
}
@override
String get downloadingDots => 'Preuzimanje...';
@override
String get cancelDownload => 'Otkaži preuzimanje';
@override
String get downloadRegionButton => 'Preuzmi regiju';
@override
String get downloadNote =>
'Napomena: Velike regije ili visoke razine zumiranja mogu zahtijevati značajno vrijeme i prostor za pohranu.';
@override
String get cacheManagement => 'Upravljanje predmemorijom';
@override
String get clearAllMaps => 'Očisti sve karte';
@override
String get clearMapsConfirmTitle => 'Očisti sve karte';
@override
String get clearMapsConfirmMessage =>
'Jeste li sigurni da želite izbrisati sve preuzete karte? Ova radnja se ne može poništiti.';
@override
String get mapDownloadCompleted => 'Preuzimanje karte završeno!';
@override
String get cacheClearedSuccessfully => 'Predmemorija uspješno očišćena!';
@override
String get downloadCancelled => 'Preuzimanje otkazano';
@override
String get startingDownload => 'Pokretanje preuzimanja...';
@override
String get downloadingMapTiles => 'Preuzimanje pločica karte...';
@override
String get downloadCompletedSuccessfully => 'Preuzimanje uspješno završeno!';
@override
String get cancellingDownload => 'Otkazivanje preuzimanja...';
@override
String errorLoadingStats(String error) {
return 'Greška pri učitavanju statistike: $error';
}
@override
String downloadFailed(String error) {
return 'Preuzimanje nije uspjelo: $error';
}
@override
String cancelFailed(String error) {
return 'Otkazivanje nije uspjelo: $error';
}
@override
String clearCacheFailed(String error) {
return 'Čišćenje predmemorije nije uspjelo: $error';
}
@override
String minZoomError(String error) {
return 'Min zumiranje: $error';
}
@override
String maxZoomError(String error) {
return 'Maks zumiranje: $error';
}
@override
String get minZoomGreaterThanMax =>
'Minimalno zumiranje mora biti manje ili jednako maksimalnom zumiranju';
@override
String get selectMapLayer => 'Odaberite sloj karte';
@override
String get mapOptions => 'Opcije karte';
@override
String get showLegend => 'Prikaži legendu';
@override
String get displayMarkerTypeCounts => 'Prikaži broj vrsta markera';
@override
String get rotateMapWithHeading => 'Rotiraj kartu sa smjerom';
@override
String get mapFollowsDirection => 'Karta slijedi vaš smjer pri kretanju';
@override
String get resetMapRotation => 'Resetiraj rotaciju';
@override
String get resetMapRotationTooltip => 'Vrati kartu na sjever';
@override
String get showMapDebugInfo =>
'Prikaži informacije za otklanjanje pogrešaka karte';
@override
String get displayZoomLevelBounds => 'Prikaži razinu zumiranja i granice';
@override
String get fullscreenMode => 'Način cijelog zaslona';
@override
String get hideUiFullMapView =>
'Sakrij sve UI kontrole za prikaz cijele karte';
@override
String get openStreetMap => 'OpenStreetMap';
@@ -1029,12 +676,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get googleTerrain => 'Google teren';
@override
String get downloadVisibleArea => 'Preuzmi vidljivo područje';
@override
String get initializingMap => 'Inicijalizacija karte...';
@override
String get dragToPosition => 'Povuci na poziciju';
@@ -1050,51 +691,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get sarAlert => 'SAR UZBUNA';
@override
String get messageSentToPublicChannel => 'Poruka poslana na javni kanal';
@override
String get pleaseSelectRoomToSendSar =>
'Molimo odaberite sobu za slanje SAR markera';
@override
String failedToSendSarMarker(String error) {
return 'Neuspjelo slanje SAR markera: $error';
}
@override
String sarMarkerSentTo(String roomName) {
return 'SAR marker poslan u $roomName';
}
@override
String get notConnectedCannotSync =>
'Nije povezano - ne može se sinkronizirati poruke';
@override
String syncedMessageCount(int count) {
return 'Sinkronizirano $count poruka';
}
@override
String get noNewMessages => 'Nema novih poruka';
@override
String syncFailed(String error) {
return 'Sinkronizacija nije uspjela: $error';
}
@override
String get failedToResendMessage => 'Neuspjelo ponovno slanje poruke';
@override
String get retryingMessage => 'Ponovni pokušaj slanja poruke...';
@override
String retryFailed(String error) {
return 'Ponovni pokušaj nije uspio: $error';
}
@override
String get textCopiedToClipboard => 'Tekst kopiran u međuspremnik';
@@ -1106,9 +702,6 @@ class AppLocalizationsHr extends AppLocalizations {
String get cannotReplyContactNotFound =>
'Ne mogu odgovoriti: kontakt nije pronađen';
@override
String get messageDeleted => 'Poruka izbrisana';
@override
String get copyText => 'Kopiraj tekst';
@@ -1144,12 +737,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get sarLocationShare => 'SAR Lokacija';
@override
String get locationShared => 'Lokacija podijeljena';
@override
String get refreshedContacts => 'Kontakti osvježeni';
@override
String get justNow => 'Upravo sada';
@@ -1257,78 +844,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get you => 'Ti';
@override
String get offlineVectorMaps => 'Offline vektorske karte';
@override
String get offlineVectorMapsDescription =>
'Uvezite i upravljajte offline vektorskim pločicama karata (MBTiles format) za upotrebu bez internetske veze';
@override
String get importMbtiles => 'Uvezi MBTiles datoteku';
@override
String get importMbtilesNote =>
'Podržava MBTiles datoteke s vektorskim pločicama (PBF/MVT format). Geofabrik izvodi odlično rade!';
@override
String get noMbtilesFiles => 'Nisu pronađene offline vektorske karte';
@override
String get mbtilesImportedSuccessfully => 'MBTiles datoteka uspješno uvezena';
@override
String get failedToImportMbtiles => 'Neuspjeli uvoz MBTiles datoteke';
@override
String get deleteMbtilesConfirmTitle => 'Izbriši offline kartu';
@override
String deleteMbtilesConfirmMessage(String name) {
return 'Jeste li sigurni da želite izbrisati \"$name\"? Ovo će trajno ukloniti offline kartu.';
}
@override
String get mbtilesDeletedSuccessfully => 'Offline karta uspješno izbrisana';
@override
String get failedToDeleteMbtiles => 'Neuspjelo brisanje offline karte';
@override
String get importExportCachedTiles => 'Uvoz/Izvoz predmemoriranih pločica';
@override
String get importExportDescription =>
'Sigurnosno kopirajte, dijelite i vraćajte preuzete pločice karte između uređaja';
@override
String get exportTilesToFile => 'Izvezi pločice u datoteku';
@override
String get importTilesFromFile => 'Uvezi pločice iz datoteke';
@override
String get selectExportLocation => 'Odaberite lokaciju izvoza';
@override
String get selectImportFile => 'Odaberite arhivu pločica';
@override
String get exportingTiles => 'Izvažanje pločica...';
@override
String get importingTiles => 'Uvažanje pločica...';
@override
String exportSuccess(int count) {
return 'Uspješno izvezeno $count pločica';
}
@override
String importSuccess(int count) {
return 'Uspješno uvezeno $count skladišta';
}
@override
String exportFailed(String error) {
return 'Izvoz nije uspio: $error';
@@ -1339,40 +854,12 @@ class AppLocalizationsHr extends AppLocalizations {
return 'Uvoz nije uspio: $error';
}
@override
String get exportNote =>
'Stvara komprimiranu arhivsku datoteku (.fmtc) koju možete dijeliti i uvesti na drugim uređajima.';
@override
String get importNote =>
'Uvozi pločice karte iz prethodno izvezene arhivske datoteke. Pločice će biti spojene s postojećom predmemorijom.';
@override
String get noTilesToExport => 'Nema pločica za izvoz';
@override
String archiveContainsStores(int count) {
return 'Arhiva sadrži $count skladišta';
}
@override
String get vectorTiles => 'Vektorske pločice';
@override
String get schema => 'Shema';
@override
String get unknown => 'Nepoznato';
@override
String get bounds => 'Granice';
@override
String get onlineLayers => 'Mrežni slojevi';
@override
String get offlineLayers => 'Offline slojevi';
@override
String get locationTrail => 'Putanja lokacije';
@@ -1411,47 +898,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get trailControls => 'Upravljanje putanjom';
@override
String get exportTrailToGpx => 'Izvezi putanju u GPX';
@override
String get importTrailFromGpx => 'Uvezi putanju iz GPX';
@override
String get trailExportedSuccessfully => 'Putanja uspješno izvezena!';
@override
String get failedToExportTrail => 'Izvoz putanje nije uspio';
@override
String failedToImportTrail(String error) {
return 'Uvoz putanje nije uspio: $error';
}
@override
String get importTrail => 'Uvezi putanju';
@override
String importTrailQuestion(int pointCount) {
return 'Uvezi putanju s $pointCount točaka?\n\nMožete zamijeniti trenutnu putanju ili je prikazati zajedno.';
}
@override
String get viewAlongside => 'Prikaži zajedno';
@override
String get replaceCurrent => 'Zamijeni trenutnu';
@override
String trailImported(int pointCount) {
return 'Putanja uvezena! ($pointCount točaka)';
}
@override
String trailReplaced(int pointCount) {
return 'Putanja zamijenjena! ($pointCount točaka)';
}
@override
String get contactTrails => 'Putanje kontakata';
@@ -1492,7 +938,7 @@ class AppLocalizationsHr extends AppLocalizations {
String get buildDate => 'Datum izgradnje';
@override
String get firmware => 'Firmware';
String get firmware => 'Firmver';
@override
String get maxContacts => 'Maks. kontakata';
@@ -1658,7 +1104,7 @@ class AppLocalizationsHr extends AppLocalizations {
String get tapAddToCreate => 'Dodirnite + za izradu prvog predloška';
@override
String get ok => 'OK';
String get ok => 'U redu';
@override
String get permissionsSection => 'Dozvole';
@@ -1723,19 +1169,12 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get noRoomsFound => 'Nema soba';
@override
String get noContactsOrRoomsAvailable => 'Nema dostupnih kontakata ili soba';
@override
String get noRecipientsAvailable => 'Nema dostupnih primatelja';
@override
String get noChannelsFound => 'Nije pronađen nijedan kanal';
@override
String get messagesWillBeSentToPublicChannel =>
'Poruke će biti poslane na javni kanal';
@override
String get newMessage => 'Nova poruka';
@@ -1915,10 +1354,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get rectangleDrawing => 'Pravokutni crtež';
@override
String get coordinatesCopiedToClipboard =>
'Koordinate kopirane u međuspremnik';
@override
String get manualCoordinates => 'Ručne koordinate';
@@ -1931,37 +1366,9 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get longitudeLabel => 'Geografska dužina';
@override
String get invalidLatitude => 'Nevažeća geografska širina (-90 do 90)';
@override
String get invalidLongitude => 'Nevažeća geografska dužina (-180 do 180)';
@override
String get exampleCoordinates => 'Primjer: 46.0569, 14.5058';
@override
String get drawingShared => 'Crtež podijeljen';
@override
String get drawingHidden => 'Crtež sakriven s karte';
@override
String alreadyShared(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count već podijeljeno',
one: '1 već podijeljeno',
);
return '$_temp0';
}
@override
String newDrawingsShared(int count, String plural) {
return 'Podijeljeno $count novi$plural crtež$plural';
}
@override
String get shareDrawing => 'Podijeli crtež';
@@ -2017,12 +1424,6 @@ class AppLocalizationsHr extends AppLocalizations {
@override
String get forestRoads => 'Šumske ceste';
@override
String get showCadastralParcels => 'Prikaži katastarske čestice';
@override
String get showForestRoads => 'Prikaži šumske ceste';
@override
String get wmsOverlays => 'WMS prekrivanja';
@@ -2126,10 +1527,6 @@ class AppLocalizationsHr extends AppLocalizations {
return 'Neuspješno brisanje kanala: $error';
}
@override
String get allChannelSlotsInUse =>
'Svi slotovi kanala su zauzeti (maksimalno 39 prilagođenih kanala)';
@override
String get createChannel => 'Kreiraj kanal';
@@ -2170,21 +1567,6 @@ class AppLocalizationsHr extends AppLocalizations {
String get wizardConnectingFeature3 =>
'Radi potpuno izvan mreže — internet nije potreban';
@override
String get wizardSimpleModeTitle => 'Jednostavan način';
@override
String get wizardSimpleModeDescription =>
'Prvi put koristite mesh mrežu? Uključite jednostavan način za pojednostavljeno sučelje s osnovnim funkcijama.';
@override
String get wizardSimpleModeFeature1 =>
'Sučelje prilagođeno početnicima s osnovnim funkcijama';
@override
String get wizardSimpleModeFeature2 =>
'U svakom trenutku prebacite na napredni način u Postavkama';
@override
String get wizardChannelTitle => 'Kanali';

View File

@@ -29,9 +29,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get disconnect => 'Disconnetti';
@override
String get scanningForDevices => 'Ricerca dispositivi in corso...';
@override
String get noDevicesFound => 'Nessun dispositivo trovato';
@@ -62,27 +59,14 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get failedToGetGpsLocation => 'Impossibile ottenere la posizione GPS';
@override
String advertisedAtLocation(String latitude, String longitude) {
return 'Annunciato a $latitude, $longitude';
}
@override
String failedToAdvertise(String error) {
return 'Annuncio fallito: $error';
}
@override
String reconnecting(int attempt, int max) {
return 'Riconnessione in corso... ($attempt/$max)';
}
@override
String get cancelReconnection => 'Annulla riconnessione';
@override
String get mapManagement => 'Gestione Mappa';
@override
String get general => 'Generale';
@@ -129,13 +113,6 @@ class AppLocalizationsIt extends AppLocalizations {
String get displayPacketActivity =>
'Mostra indicatori di attività pacchetti nella barra superiore';
@override
String get simpleMode => 'Modalità Semplice';
@override
String get simpleModeDescription =>
'Nascondi informazioni non essenziali nei messaggi e contatti';
@override
String get disableMap => 'Disabilita mappa';
@@ -149,75 +126,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get chooseLanguage => 'Scegli Lingua';
@override
String get english => 'Inglese';
@override
String get slovenian => 'Sloveno';
@override
String get croatian => 'Croato';
@override
String get german => 'Tedesco';
@override
String get spanish => 'Spagnolo';
@override
String get french => 'Francese';
@override
String get italian => 'Italiano';
@override
String get locationBroadcasting => 'Trasmissione Posizione';
@override
String get autoLocationTracking => 'Tracciamento Posizione Automatico';
@override
String get automaticallyBroadcastPosition =>
'Trasmetti automaticamente aggiornamenti di posizione';
@override
String get configureTracking => 'Configura Tracciamento';
@override
String get distanceAndTimeThresholds => 'Soglie di distanza e tempo';
@override
String get locationTrackingConfiguration =>
'Configurazione Tracciamento Posizione';
@override
String get configureWhenLocationBroadcasts =>
'Configura quando le trasmissioni di posizione vengono inviate alla rete mesh';
@override
String get minimumDistance => 'Distanza Minima';
@override
String broadcastAfterMoving(String distance) {
return 'Trasmetti solo dopo essersi spostati di $distance metri';
}
@override
String get maximumDistance => 'Distanza Massima';
@override
String alwaysBroadcastAfterMoving(String distance) {
return 'Trasmetti sempre dopo essersi spostati di $distance metri';
}
@override
String get minimumTimeInterval => 'Intervallo Minimo di Tempo';
@override
String alwaysBroadcastEvery(String duration) {
return 'Trasmetti sempre ogni $duration';
}
@override
String get save => 'Salva';
@@ -253,12 +161,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get moreInfo => 'Maggiori informazioni';
@override
String get learnMoreAbout => 'Ulteriori informazioni su MeshCore SAR';
@override
String get developer => 'Sviluppatore';
@override
String get packageName => 'Nome Pacchetto';
@@ -335,9 +237,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get refresh => 'Aggiorna';
@override
String get sendDirectMessage => 'Invia';
@override
String get resetPath => 'Resetta Percorso (Ri-instrada)';
@@ -458,21 +357,6 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Condividi $count disegno$plural';
}
@override
String sentDrawingsToRoom(int count, String plural, String roomName) {
return 'Inviati $count disegno$plural mappa a $roomName';
}
@override
String sharedDrawingsToRoom(
int success,
int total,
String plural,
String roomName,
) {
return 'Condivisi $success/$total disegno$plural con $roomName';
}
@override
String get showReceivedDrawings => 'Mostra Disegni Ricevuti';
@@ -494,9 +378,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get clearAll => 'Cancella Tutto';
@override
String get noLocalDrawings => 'Nessun disegno locale da condividere';
@override
String get publicChannel => 'Canale Pubblico';
@@ -506,37 +387,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get storedPermanently => 'Archiviato permanentemente nella stanza';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return '$count disegno$plural mappa inviato al Canale Pubblico';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return '$success/$total disegni condivisi sul Canale Pubblico';
}
@override
String get notConnectedToDevice => 'Non connesso al dispositivo';
@override
String get directMessage => 'Messaggio Diretto';
@override
String directMessageSentTo(String contactName) {
return 'Messaggio diretto inviato a $contactName';
}
@override
String failedToSend(String error) {
return 'Invio fallito: $error';
}
@override
String directMessageInfo(String contactName) {
return 'Questo messaggio verrà inviato direttamente a $contactName. Apparirà anche nel feed dei messaggi principali.';
}
@override
String get typeYourMessage => 'Scrivi il tuo messaggio...';
@@ -566,9 +419,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get location => 'Posizione';
@override
String get myLocation => 'La mia posizione';
@override
String get fromMap => 'Dalla Mappa';
@@ -630,9 +480,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get flood => 'Flood';
@override
String get admin => 'Admin';
@override
String get loggedIn => 'Connesso';
@@ -642,29 +489,11 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get distance => 'Distanza';
@override
String pingingDirect(String name) {
return 'Ping $name (diretto via percorso)...';
}
@override
String pingingFlood(String name) {
return 'Ping $name (flooding - nessun percorso)...';
}
@override
String directPingTimeout(String name) {
return 'Timeout ping diretto - nuovo tentativo $name con flooding...';
}
@override
String pingSuccessful(String name, String fallback) {
return 'Ping riuscito a $name$fallback';
}
@override
String get viaFloodingFallback => ' (via fallback flooding)';
@override
String pingFailed(String name) {
return 'Ping fallito a $name - nessuna risposta ricevuta';
@@ -675,16 +504,6 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Sei sicuro di voler eliminare \"$name\"?\n\nQuesto rimuoverà il contatto sia dall\'app che dal dispositivo radio companion.';
}
@override
String removingContact(String name) {
return 'Rimozione di $name...';
}
@override
String contactRemoved(String name) {
return 'Contatto \"$name\" rimosso';
}
@override
String failedToRemoveContact(String error) {
return 'Impossibile rimuovere il contatto: $error';
@@ -729,11 +548,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get telemetry => 'Telemetria';
@override
String requestingTelemetry(String name) {
return 'Richiesta telemetria da $name...';
}
@override
String get voltage => 'Tensione';
@@ -805,9 +619,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get showAll => 'Mostra Tutto';
@override
String get nearbyContacts => 'Contatti Vicini';
@override
String get locationUnavailable => 'Posizione non disponibile';
@@ -848,176 +659,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get channels => 'Canali';
@override
String get cacheStatistics => 'Statistiche Cache';
@override
String get totalTiles => 'Tile Totali';
@override
String get cacheSize => 'Dimensione Cache';
@override
String get storeName => 'Nome Archivio';
@override
String get noCacheStatistics => 'Nessuna statistica cache disponibile';
@override
String get downloadRegion => 'Scarica Regione';
@override
String get mapLayer => 'Livello Mappa';
@override
String get regionBounds => 'Limiti Regione';
@override
String get north => 'Nord';
@override
String get south => 'Sud';
@override
String get east => 'Est';
@override
String get west => 'Ovest';
@override
String get zoomLevels => 'Livelli di Zoom';
@override
String minZoom(int zoom) {
return 'Min: $zoom';
}
@override
String maxZoom(int zoom) {
return 'Max: $zoom';
}
@override
String get downloadingDots => 'Scaricamento in corso...';
@override
String get cancelDownload => 'Annulla Download';
@override
String get downloadRegionButton => 'Scarica Regione';
@override
String get downloadNote =>
'Nota: Regioni grandi o livelli di zoom elevati possono richiedere tempo e spazio di archiviazione significativi.';
@override
String get cacheManagement => 'Gestione Cache';
@override
String get clearAllMaps => 'Cancella Tutte le Mappe';
@override
String get clearMapsConfirmTitle => 'Cancella Tutte le Mappe';
@override
String get clearMapsConfirmMessage =>
'Sei sicuro di voler eliminare tutte le mappe scaricate? Questa azione non può essere annullata.';
@override
String get mapDownloadCompleted => 'Download mappa completato!';
@override
String get cacheClearedSuccessfully => 'Cache cancellata con successo!';
@override
String get downloadCancelled => 'Download annullato';
@override
String get startingDownload => 'Avvio download...';
@override
String get downloadingMapTiles => 'Scaricamento tile mappa...';
@override
String get downloadCompletedSuccessfully =>
'Download completato con successo!';
@override
String get cancellingDownload => 'Annullamento download...';
@override
String errorLoadingStats(String error) {
return 'Errore nel caricamento delle statistiche: $error';
}
@override
String downloadFailed(String error) {
return 'Download fallito: $error';
}
@override
String cancelFailed(String error) {
return 'Annullamento fallito: $error';
}
@override
String clearCacheFailed(String error) {
return 'Cancellazione cache fallita: $error';
}
@override
String minZoomError(String error) {
return 'Zoom minimo: $error';
}
@override
String maxZoomError(String error) {
return 'Zoom massimo: $error';
}
@override
String get minZoomGreaterThanMax =>
'Lo zoom minimo deve essere minore o uguale allo zoom massimo';
@override
String get selectMapLayer => 'Seleziona Livello Mappa';
@override
String get mapOptions => 'Opzioni Mappa';
@override
String get showLegend => 'Mostra Legenda';
@override
String get displayMarkerTypeCounts =>
'Visualizza conteggio tipi di marcatori';
@override
String get rotateMapWithHeading => 'Ruota Mappa con Direzione';
@override
String get mapFollowsDirection =>
'La mappa segue la tua direzione quando ti muovi';
@override
String get resetMapRotation => 'Ripristina Rotazione';
@override
String get resetMapRotationTooltip => 'Ripristina mappa verso nord';
@override
String get showMapDebugInfo => 'Mostra Info Debug Mappa';
@override
String get displayZoomLevelBounds => 'Visualizza livello di zoom e limiti';
@override
String get fullscreenMode => 'Modalità Schermo Intero';
@override
String get hideUiFullMapView =>
'Nascondi tutti i controlli UI per la visualizzazione completa della mappa';
@override
String get openStreetMap => 'OpenStreetMap';
@@ -1036,12 +680,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get googleTerrain => 'Google Terreno';
@override
String get downloadVisibleArea => 'Scarica area visibile';
@override
String get initializingMap => 'Inizializzazione mappa...';
@override
String get dragToPosition => 'Trascina per Posizionare';
@@ -1057,52 +695,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get sarAlert => 'ALLERTA SAR';
@override
String get messageSentToPublicChannel =>
'Messaggio inviato al canale pubblico';
@override
String get pleaseSelectRoomToSendSar =>
'Seleziona una stanza per inviare il marcatore SAR';
@override
String failedToSendSarMarker(String error) {
return 'Impossibile inviare il marcatore SAR: $error';
}
@override
String sarMarkerSentTo(String roomName) {
return 'Marcatore SAR inviato a $roomName';
}
@override
String get notConnectedCannotSync =>
'Non connesso - impossibile sincronizzare i messaggi';
@override
String syncedMessageCount(int count) {
return 'Sincronizzati $count messaggio(i)';
}
@override
String get noNewMessages => 'Nessun nuovo messaggio';
@override
String syncFailed(String error) {
return 'Sincronizzazione fallita: $error';
}
@override
String get failedToResendMessage => 'Impossibile reinviare il messaggio';
@override
String get retryingMessage => 'Nuovo tentativo messaggio...';
@override
String retryFailed(String error) {
return 'Nuovo tentativo fallito: $error';
}
@override
String get textCopiedToClipboard => 'Testo copiato negli appunti';
@@ -1114,9 +706,6 @@ class AppLocalizationsIt extends AppLocalizations {
String get cannotReplyContactNotFound =>
'Impossibile rispondere: contatto non trovato';
@override
String get messageDeleted => 'Messaggio eliminato';
@override
String get copyText => 'Copia testo';
@@ -1152,12 +741,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get sarLocationShare => 'Posizione SAR';
@override
String get locationShared => 'Posizione condivisa';
@override
String get refreshedContacts => 'Contatti aggiornati';
@override
String get justNow => 'Proprio ora';
@@ -1265,124 +848,22 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get you => 'Tu';
@override
String get offlineVectorMaps => 'Mappe Vettoriali Offline';
@override
String get offlineVectorMapsDescription =>
'Importa e gestisci tile di mappe vettoriali offline (formato MBTiles) per l\'uso senza connessione internet';
@override
String get importMbtiles => 'Importa File MBTiles';
@override
String get importMbtilesNote =>
'Supporta file MBTiles con tile vettoriali (formato PBF/MVT). Gli estratti Geofabrik funzionano benissimo!';
@override
String get noMbtilesFiles => 'Nessuna mappa vettoriale offline trovata';
@override
String get mbtilesImportedSuccessfully =>
'File MBTiles importato con successo';
@override
String get failedToImportMbtiles => 'Impossibile importare il file MBTiles';
@override
String get deleteMbtilesConfirmTitle => 'Elimina Mappa Offline';
@override
String deleteMbtilesConfirmMessage(String name) {
return 'Sei sicuro di voler eliminare \"$name\"? Questo rimuoverà permanentemente la mappa offline.';
}
@override
String get mbtilesDeletedSuccessfully =>
'Mappa offline eliminata con successo';
@override
String get failedToDeleteMbtiles => 'Impossibile eliminare la mappa offline';
@override
String get importExportCachedTiles => 'Importa/Esporta tile in cache';
@override
String get importExportDescription =>
'Esegui backup, condividi e ripristina tile mappa scaricati tra dispositivi';
@override
String get exportTilesToFile => 'Esporta tile su file';
@override
String get importTilesFromFile => 'Importa tile da file';
@override
String get selectExportLocation => 'Seleziona posizione esportazione';
@override
String get selectImportFile => 'Seleziona archivio tile';
@override
String get exportingTiles => 'Esportazione tile...';
@override
String get importingTiles => 'Importazione tile...';
@override
String exportSuccess(int count) {
return '$count tile esportati con successo';
}
@override
String importSuccess(int count) {
return '$count archivi importati con successo';
}
@override
String exportFailed(String error) {
return 'Export failed: $error';
return 'Esportazione non riuscita: $error';
}
@override
String importFailed(String error) {
return 'Import failed: $error';
return 'Importazione non riuscita: $error';
}
@override
String get exportNote =>
'Crea un file archivio compresso (.fmtc) che può essere condiviso e importato su altri dispositivi.';
@override
String get importNote =>
'Importa tile mappa da un file archivio precedentemente esportato. I tile verranno uniti con la cache esistente.';
@override
String get noTilesToExport => 'Nessun tile da esportare';
@override
String archiveContainsStores(int count) {
return 'L\'archivio contiene $count archivi';
}
@override
String get vectorTiles => 'Tile Vettoriali';
@override
String get schema => 'Schema';
@override
String get unknown => 'Sconosciuto';
@override
String get bounds => 'Limiti';
@override
String get onlineLayers => 'Livelli Online';
@override
String get offlineLayers => 'Livelli Offline';
@override
String get locationTrail => 'Traccia Posizione';
@@ -1422,47 +903,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get trailControls => 'Controlli Traccia';
@override
String get exportTrailToGpx => 'Esporta traccia in GPX';
@override
String get importTrailFromGpx => 'Importa traccia da GPX';
@override
String get trailExportedSuccessfully => 'Traccia esportata con successo!';
@override
String get failedToExportTrail => 'Esportazione traccia fallita';
@override
String failedToImportTrail(String error) {
return 'Importazione traccia fallita: $error';
}
@override
String get importTrail => 'Importa traccia';
@override
String importTrailQuestion(int pointCount) {
return 'Importare traccia con $pointCount punti?\n\nPuoi sostituire la tua traccia attuale o visualizzarla affiancata.';
}
@override
String get viewAlongside => 'Visualizza affiancata';
@override
String get replaceCurrent => 'Sostituisci attuale';
@override
String trailImported(int pointCount) {
return 'Traccia importata! ($pointCount punti)';
}
@override
String trailReplaced(int pointCount) {
return 'Traccia sostituita! ($pointCount punti)';
}
@override
String get contactTrails => 'Tracce contatti';
@@ -1584,7 +1024,7 @@ class AppLocalizationsIt extends AppLocalizations {
String get templateName => 'Nome modello';
@override
String get templateNameHint => 'e.g. Found Person';
String get templateNameHint => 'ad es. Persona trovata';
@override
String get templateEmoji => 'Emoji';
@@ -1596,16 +1036,16 @@ class AppLocalizationsIt extends AppLocalizations {
String get nameRequired => 'Nome è obbligatorio';
@override
String get templateDescription => 'Description (Optional)';
String get templateDescription => 'Descrizione (facoltativa)';
@override
String get templateDescriptionHint => 'Add additional context...';
String get templateDescriptionHint => 'Aggiungi contesto aggiuntivo...';
@override
String get templateColor => 'Color';
String get templateColor => 'Colore';
@override
String get previewFormat => 'Preview (SAR Message Format)';
String get previewFormat => 'Anteprima (formato messaggio SAR)';
@override
String get importFromClipboard => 'Importa';
@@ -1615,26 +1055,26 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String deleteTemplateConfirmation(String name) {
return 'Delete template \'$name\'?';
return 'Eliminare il modello \'$name\'?';
}
@override
String get templateAdded => 'Template added';
String get templateAdded => 'Modello aggiunto';
@override
String get templateUpdated => 'Template updated';
String get templateUpdated => 'Modello aggiornato';
@override
String get templateDeleted => 'Template deleted';
String get templateDeleted => 'Modello eliminato';
@override
String templatesImported(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Imported $count templates',
one: 'Imported 1 template',
zero: 'No templates imported',
other: 'Importati $count modelli',
one: 'Importato 1 modello',
zero: 'Nessun modello importato',
);
return '$_temp0';
}
@@ -1644,8 +1084,8 @@ class AppLocalizationsIt extends AppLocalizations {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Exported $count templates to clipboard',
one: 'Exported 1 template to clipboard',
other: 'Esportati $count modelli negli appunti',
one: 'Esportato 1 modello negli appunti',
);
return '$_temp0';
}
@@ -1664,10 +1104,10 @@ class AppLocalizationsIt extends AppLocalizations {
String get resetComplete => 'Modelli ripristinati ai valori predefiniti';
@override
String get noTemplates => 'No templates available';
String get noTemplates => 'Nessun modello disponibile';
@override
String get tapAddToCreate => 'Tap + to create your first template';
String get tapAddToCreate => 'Tocca + per creare il tuo primo modello';
@override
String get ok => 'OK';
@@ -1734,20 +1174,12 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get noRoomsFound => 'Nessuna stanza trovata';
@override
String get noContactsOrRoomsAvailable =>
'Nessun contatto o stanza disponibile';
@override
String get noRecipientsAvailable => 'Nessun destinatario disponibile';
@override
String get noChannelsFound => 'Nessun canale trovato';
@override
String get messagesWillBeSentToPublicChannel =>
'I messaggi saranno inviati al canale pubblico';
@override
String get newMessage => 'Nuovo messaggio';
@@ -1932,9 +1364,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get rectangleDrawing => 'Rettangolo';
@override
String get coordinatesCopiedToClipboard => 'Coordinate copiate negli appunti';
@override
String get manualCoordinates => 'Coordinate Manuali';
@@ -1947,31 +1376,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get longitudeLabel => 'Longitudine';
@override
String get invalidLatitude => 'Latitudine non valida (-90 a 90)';
@override
String get invalidLongitude => 'Longitudine non valida (-180 a 180)';
@override
String get exampleCoordinates => 'Esempio: 46.0569, 14.5058';
@override
String get drawingShared => 'Disegno della Mappa';
@override
String get drawingHidden => 'Disegno nascosto dalla mappa';
@override
String alreadyShared(int count) {
return '$count già condiviso';
}
@override
String newDrawingsShared(int count, String plural) {
return '$count nuovo(i) disegno(i) condiviso(i)';
}
@override
String get shareDrawing => 'Condividi Disegno';
@@ -2028,12 +1435,6 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get forestRoads => 'Strade Forestali';
@override
String get showCadastralParcels => 'Mostra particelle catastali';
@override
String get showForestRoads => 'Mostra strade forestali';
@override
String get wmsOverlays => 'Sovrapposizioni WMS';
@@ -2138,10 +1539,6 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Eliminazione del canale fallita: $error';
}
@override
String get allChannelSlotsInUse =>
'Tutti gli slot dei canali sono in uso (massimo 39 canali personalizzati)';
@override
String get createChannel => 'Crea Canale';
@@ -2183,21 +1580,6 @@ class AppLocalizationsIt extends AppLocalizations {
String get wizardConnectingFeature3 =>
'Funziona completamente offline - non è richiesta connessione internet';
@override
String get wizardSimpleModeTitle => 'Modalità Semplice';
@override
String get wizardSimpleModeDescription =>
'Nuovo alle reti mesh? Abilita la modalità semplice per un\'interfaccia semplificata con solo le funzioni essenziali.';
@override
String get wizardSimpleModeFeature1 =>
'Interfaccia intuitiva per principianti con funzioni principali';
@override
String get wizardSimpleModeFeature2 =>
'Passa alla modalità avanzata in qualsiasi momento dalle Impostazioni';
@override
String get wizardChannelTitle => 'Canali';

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

@@ -29,9 +29,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get disconnect => 'Prekini';
@override
String get scanningForDevices => 'Iskanje naprav...';
@override
String get noDevicesFound => 'Ni najdenih naprav';
@@ -62,27 +59,14 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get failedToGetGpsLocation => 'Pridobitev GPS lokacije ni uspela';
@override
String advertisedAtLocation(String latitude, String longitude) {
return 'Objavljeno na $latitude, $longitude';
}
@override
String failedToAdvertise(String error) {
return 'Objava ni uspela: $error';
}
@override
String reconnecting(int attempt, int max) {
return 'Ponovno povezovanje... ($attempt/$max)';
}
@override
String get cancelReconnection => 'Prekliči ponovno povezovanje';
@override
String get mapManagement => 'Upravljanje zemljevida';
@override
String get general => 'Splošno';
@@ -129,13 +113,6 @@ class AppLocalizationsSl extends AppLocalizations {
String get displayPacketActivity =>
'Prikaži kazalnike aktivnosti paketov v zgornji vrstici';
@override
String get simpleMode => 'Preprost način';
@override
String get simpleModeDescription =>
'Skrij nepomembne informacije v sporočilih in kontaktih';
@override
String get disableMap => 'Onemogoči zemljevid';
@@ -149,74 +126,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get chooseLanguage => 'Izberite jezik';
@override
String get english => 'Angleščina';
@override
String get slovenian => 'Slovenščina';
@override
String get croatian => 'Hrvaščina';
@override
String get german => 'Nemščina';
@override
String get spanish => 'Španščina';
@override
String get french => 'Francoščina';
@override
String get italian => 'Italijanščina';
@override
String get locationBroadcasting => 'Oddajanje lokacije';
@override
String get autoLocationTracking => 'Samodejno sledenje lokaciji';
@override
String get automaticallyBroadcastPosition =>
'Samodejno oddajaj posodobitve položaja';
@override
String get configureTracking => 'Konfiguriraj sledenje';
@override
String get distanceAndTimeThresholds => 'Pragovi razdalje in časa';
@override
String get locationTrackingConfiguration => 'Konfiguracija sledenja lokaciji';
@override
String get configureWhenLocationBroadcasts =>
'Konfigurirajte, kdaj se oddajanja lokacije pošiljajo v omrežje mesh';
@override
String get minimumDistance => 'Minimalna razdalja';
@override
String broadcastAfterMoving(String distance) {
return 'Oddajaj šele po premiku $distance metrov';
}
@override
String get maximumDistance => 'Maksimalna razdalja';
@override
String alwaysBroadcastAfterMoving(String distance) {
return 'Vedno oddajaj po premiku $distance metrov';
}
@override
String get minimumTimeInterval => 'Minimalni časovni interval';
@override
String alwaysBroadcastEvery(String duration) {
return 'Vedno oddajaj vsakih $duration';
}
@override
String get save => 'Shrani';
@@ -252,12 +161,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get moreInfo => 'Več informacij';
@override
String get learnMoreAbout => 'Več o MeshCore SAR';
@override
String get developer => 'Razvijalec';
@override
String get packageName => 'Ime paketa';
@@ -333,9 +236,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get refresh => 'Osveži';
@override
String get sendDirectMessage => 'Pošlji';
@override
String get resetPath => 'Ponastavi pot (preusmeri)';
@@ -454,21 +354,6 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Deli $count risb$plural';
}
@override
String sentDrawingsToRoom(int count, String plural, String roomName) {
return 'Poslanih $count risb$plural karte v $roomName';
}
@override
String sharedDrawingsToRoom(
int success,
int total,
String plural,
String roomName,
) {
return 'Deljeno $success/$total risb$plural v $roomName';
}
@override
String get showReceivedDrawings => 'Prikaži prejete risbe';
@@ -490,9 +375,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get clearAll => 'Počisti vse';
@override
String get noLocalDrawings => 'Ni lokalnih risb za deljenje';
@override
String get publicChannel => 'Javni kanal';
@@ -502,37 +384,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get storedPermanently => 'Trajno shranjeno v sobi';
@override
String drawingsSentToPublicChannel(int count, String plural) {
return 'Poslano $count risb$plural na javni kanal';
}
@override
String drawingsSharedToPublicChannel(int success, int total) {
return 'Deljeno $success/$total risb na javni kanal';
}
@override
String get notConnectedToDevice => 'Ni povezano z napravo';
@override
String get directMessage => 'Neposredno sporočilo';
@override
String directMessageSentTo(String contactName) {
return 'Neposredno sporočilo poslano $contactName';
}
@override
String failedToSend(String error) {
return 'Pošiljanje ni uspelo: $error';
}
@override
String directMessageInfo(String contactName) {
return 'To sporočilo bo poslano neposredno $contactName. Prikazalo se bo tudi v glavnem viru sporočil.';
}
@override
String get typeYourMessage => 'Vnesite svoje sporočilo...';
@@ -562,9 +416,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get location => 'Lokacija';
@override
String get myLocation => 'Moja lokacija';
@override
String get fromMap => 'Z zemljevida';
@@ -626,9 +477,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get flood => 'Razpršitev';
@override
String get admin => 'Administrator';
@override
String get loggedIn => 'Prijavljen';
@@ -638,29 +486,11 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get distance => 'Razdalja';
@override
String pingingDirect(String name) {
return 'Pinganje $name (neposredno preko poti)...';
}
@override
String pingingFlood(String name) {
return 'Pinganje $name (razpršitev - brez poti)...';
}
@override
String directPingTimeout(String name) {
return 'Časovna omejitev neposrednega pinga - ponovni poskus $name z razprševanjem...';
}
@override
String pingSuccessful(String name, String fallback) {
return 'Ping uspešen do $name$fallback';
}
@override
String get viaFloodingFallback => ' (preko rezervnega razprševanja)';
@override
String pingFailed(String name) {
return 'Ping neuspešen do $name - odgovor ni prejet';
@@ -671,16 +501,6 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Ste prepričani, da želite izbrisati \"$name\"?\n\nTo bo odstranilo stik iz aplikacije in spremljevalne radijske naprave.';
}
@override
String removingContact(String name) {
return 'Odstranjevanje $name...';
}
@override
String contactRemoved(String name) {
return 'Stik \"$name\" odstranjen';
}
@override
String failedToRemoveContact(String error) {
return 'Odstranjevanje stika ni uspelo: $error';
@@ -725,11 +545,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get telemetry => 'Telemetrija';
@override
String requestingTelemetry(String name) {
return 'Zahtevanje telemetrije od $name...';
}
@override
String get voltage => 'Napetost';
@@ -801,9 +616,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get showAll => 'Prikaži vse';
@override
String get nearbyContacts => 'Bližnji stiki';
@override
String get locationUnavailable => 'Lokacija ni na voljo';
@@ -844,174 +656,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get channels => 'Kanali';
@override
String get cacheStatistics => 'Statistika predpomnilnika';
@override
String get totalTiles => 'Skupaj ploščic';
@override
String get cacheSize => 'Velikost predpomnilnika';
@override
String get storeName => 'Ime skladišča';
@override
String get noCacheStatistics => 'Statistika predpomnilnika ni na voljo';
@override
String get downloadRegion => 'Prenesi regijo';
@override
String get mapLayer => 'Sloj zemljevida';
@override
String get regionBounds => 'Meje regije';
@override
String get north => 'Sever';
@override
String get south => 'Jug';
@override
String get east => 'Vzhod';
@override
String get west => 'Zahod';
@override
String get zoomLevels => 'Nivoji povečave';
@override
String minZoom(int zoom) {
return 'Min: $zoom';
}
@override
String maxZoom(int zoom) {
return 'Maks: $zoom';
}
@override
String get downloadingDots => 'Prenašanje...';
@override
String get cancelDownload => 'Prekliči prenos';
@override
String get downloadRegionButton => 'Prenesi regijo';
@override
String get downloadNote =>
'Opomba: Velike regije ali visoki nivoji povečave lahko zahtevajo veliko časa in prostora za shranjevanje.';
@override
String get cacheManagement => 'Upravljanje predpomnilnika';
@override
String get clearAllMaps => 'Počisti vse zemljevide';
@override
String get clearMapsConfirmTitle => 'Počisti vse zemljevide';
@override
String get clearMapsConfirmMessage =>
'Ste prepričani, da želite izbrisati vse prenesene zemljevide? Tega dejanja ni mogoče razveljaviti.';
@override
String get mapDownloadCompleted => 'Prenos zemljevida končan!';
@override
String get cacheClearedSuccessfully => 'Predpomnilnik uspešno počiščen!';
@override
String get downloadCancelled => 'Prenos preklican';
@override
String get startingDownload => 'Začetek prenosa...';
@override
String get downloadingMapTiles => 'Prenašanje ploščic zemljevida...';
@override
String get downloadCompletedSuccessfully => 'Prenos uspešno končan!';
@override
String get cancellingDownload => 'Preklic prenosa...';
@override
String errorLoadingStats(String error) {
return 'Napaka pri nalaganju statistike: $error';
}
@override
String downloadFailed(String error) {
return 'Prenos ni uspel: $error';
}
@override
String cancelFailed(String error) {
return 'Preklic ni uspel: $error';
}
@override
String clearCacheFailed(String error) {
return 'Čiščenje predpomnilnika ni uspelo: $error';
}
@override
String minZoomError(String error) {
return 'Min povečava: $error';
}
@override
String maxZoomError(String error) {
return 'Maks povečava: $error';
}
@override
String get minZoomGreaterThanMax =>
'Minimalna povečava mora biti manjša ali enaka maksimalni povečavi';
@override
String get selectMapLayer => 'Izberite sloj zemljevida';
@override
String get mapOptions => 'Možnosti zemljevida';
@override
String get showLegend => 'Prikaži legendo';
@override
String get displayMarkerTypeCounts => 'Prikaži število vrst označevalcev';
@override
String get rotateMapWithHeading => 'Rotiraj zemljevid s smerjo';
@override
String get mapFollowsDirection => 'Zemljevid sledi vaši smeri pri gibanju';
@override
String get resetMapRotation => 'Ponastavi rotacijo';
@override
String get resetMapRotationTooltip => 'Ponastavi zemljevid na sever';
@override
String get showMapDebugInfo =>
'Prikaži informacije za razhroščevanje zemljevida';
@override
String get displayZoomLevelBounds => 'Prikaži nivo povečave in meje';
@override
String get fullscreenMode => 'Način celozaslonskega prikaza';
@override
String get hideUiFullMapView =>
'Skrij vse UI kontrole za poln prikaz zemljevida';
@override
String get openStreetMap => 'OpenStreetMap';
@@ -1030,12 +677,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get googleTerrain => 'Google teren';
@override
String get downloadVisibleArea => 'Prenesi vidno območje';
@override
String get initializingMap => 'Inicializacija zemljevida...';
@override
String get dragToPosition => 'Povleci na položaj';
@@ -1051,51 +692,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get sarAlert => 'SAR ALARM';
@override
String get messageSentToPublicChannel => 'Sporočilo poslano na javni kanal';
@override
String get pleaseSelectRoomToSendSar =>
'Prosimo, izberite sobo za pošiljanje SAR označevalca';
@override
String failedToSendSarMarker(String error) {
return 'Pošiljanje SAR označevalca ni uspelo: $error';
}
@override
String sarMarkerSentTo(String roomName) {
return 'SAR označevalec poslan v $roomName';
}
@override
String get notConnectedCannotSync =>
'Ni povezano - sporočil ni mogoče sinhronizirati';
@override
String syncedMessageCount(int count) {
return 'Sinhronizirano $count sporočil';
}
@override
String get noNewMessages => 'Ni novih sporočil';
@override
String syncFailed(String error) {
return 'Sinhronizacija ni uspela: $error';
}
@override
String get failedToResendMessage => 'Ponovno pošiljanje sporočila ni uspelo';
@override
String get retryingMessage => 'Ponovni poskus pošiljanja sporočila...';
@override
String retryFailed(String error) {
return 'Ponovni poskus ni uspel: $error';
}
@override
String get textCopiedToClipboard => 'Besedilo kopirano v odložišče';
@@ -1107,9 +703,6 @@ class AppLocalizationsSl extends AppLocalizations {
String get cannotReplyContactNotFound =>
'Ni mogoče odgovoriti: stik ni najden';
@override
String get messageDeleted => 'Sporočilo izbrisano';
@override
String get copyText => 'Kopiraj besedilo';
@@ -1145,12 +738,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get sarLocationShare => 'SAR Lokacija';
@override
String get locationShared => 'Lokacija deljena';
@override
String get refreshedContacts => 'Stiki osveženi';
@override
String get justNow => 'Pravkar';
@@ -1258,81 +845,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get you => 'Ti';
@override
String get offlineVectorMaps => 'Brezpovezni vektorski zemljevidi';
@override
String get offlineVectorMapsDescription =>
'Uvozite in upravljajte brezpovezne vektorske ploščice zemljevidov (format MBTiles) za uporabo brez internetne povezave';
@override
String get importMbtiles => 'Uvozi MBTiles datoteko';
@override
String get importMbtilesNote =>
'Podpira MBTiles datoteke z vektorskimi ploščicami (format PBF/MVT). Geofabrik izvozi odlično delujejo!';
@override
String get noMbtilesFiles =>
'Ni najdenih brezpoveznih vektorskih zemljevidov';
@override
String get mbtilesImportedSuccessfully => 'MBTiles datoteka uspešno uvožena';
@override
String get failedToImportMbtiles => 'Uvoz MBTiles datoteke ni uspel';
@override
String get deleteMbtilesConfirmTitle => 'Izbriši brezpovezni zemljevid';
@override
String deleteMbtilesConfirmMessage(String name) {
return 'Ste prepričani, da želite izbrisati \"$name\"? To bo trajno odstranilo brezpovezni zemljevid.';
}
@override
String get mbtilesDeletedSuccessfully =>
'Brezpovezni zemljevid uspešno izbrisan';
@override
String get failedToDeleteMbtiles =>
'Brisanje brezpoveznega zemljevida ni uspelo';
@override
String get importExportCachedTiles => 'Uvoz/Izvoz predpomnjenih ploščic';
@override
String get importExportDescription =>
'Varnostno kopirajte, delite in obnovite prenesene ploščice zemljevida med napravami';
@override
String get exportTilesToFile => 'Izvozi ploščice v datoteko';
@override
String get importTilesFromFile => 'Uvozi ploščice iz datoteke';
@override
String get selectExportLocation => 'Izberi lokacijo izvoza';
@override
String get selectImportFile => 'Izberi arhiv ploščic';
@override
String get exportingTiles => 'Izvažanje ploščic...';
@override
String get importingTiles => 'Uvažanje ploščic...';
@override
String exportSuccess(int count) {
return 'Uspešno izvoženih $count ploščic';
}
@override
String importSuccess(int count) {
return 'Uspešno uvoženih $count skladišč';
}
@override
String exportFailed(String error) {
return 'Izvoz ni uspel: $error';
@@ -1343,40 +855,12 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Uvoz ni uspel: $error';
}
@override
String get exportNote =>
'Ustvari stisnjeno arhivsko datoteko (.fmtc), ki jo lahko delite in uvozite na drugih napravah.';
@override
String get importNote =>
'Uvozi ploščice zemljevida iz predhodno izvožene arhivske datoteke. Ploščice bodo združene z obstoječim predpomnilnikom.';
@override
String get noTilesToExport => 'Ni ploščic za izvoz';
@override
String archiveContainsStores(int count) {
return 'Arhiv vsebuje $count skladišč';
}
@override
String get vectorTiles => 'Vektorske ploščice';
@override
String get schema => 'Shema';
@override
String get unknown => 'Neznano';
@override
String get bounds => 'Meje';
@override
String get onlineLayers => 'Spletne plasti';
@override
String get offlineLayers => 'Brezpovezne plasti';
@override
String get locationTrail => 'Sledilna pot';
@@ -1415,47 +899,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get trailControls => 'Nadzor poti';
@override
String get exportTrailToGpx => 'Izvozi pot v GPX';
@override
String get importTrailFromGpx => 'Uvozi pot iz GPX';
@override
String get trailExportedSuccessfully => 'Pot uspešno izvožena!';
@override
String get failedToExportTrail => 'Izvoz poti ni uspel';
@override
String failedToImportTrail(String error) {
return 'Uvoz poti ni uspel: $error';
}
@override
String get importTrail => 'Uvozi pot';
@override
String importTrailQuestion(int pointCount) {
return 'Uvozi pot s $pointCount točkami?\n\nLahko zamenjate trenutno pot ali jo prikažete skupaj.';
}
@override
String get viewAlongside => 'Prikaži skupaj';
@override
String get replaceCurrent => 'Zamenjaj trenutno';
@override
String trailImported(int pointCount) {
return 'Pot uvožena! ($pointCount točk)';
}
@override
String trailReplaced(int pointCount) {
return 'Pot zamenjana! ($pointCount točk)';
}
@override
String get contactTrails => 'Poti stikov';
@@ -1661,7 +1104,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get tapAddToCreate => 'Tapnite + za ustvarjanje prve predloge';
@override
String get ok => 'OK';
String get ok => 'V redu';
@override
String get permissionsSection => 'Dovoljenja';
@@ -1725,19 +1168,12 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get noRoomsFound => 'Ni sob';
@override
String get noContactsOrRoomsAvailable => 'Ni na voljo kontaktov ali sob';
@override
String get noRecipientsAvailable => 'Ni na voljo prejemnikov';
@override
String get noChannelsFound => 'Ni najdenih kanalov';
@override
String get messagesWillBeSentToPublicChannel =>
'Sporočila bodo poslana na javni kanal';
@override
String get newMessage => 'Novo sporočilo';
@@ -1918,9 +1354,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get rectangleDrawing => 'Pravokotna risba';
@override
String get coordinatesCopiedToClipboard => 'Koordinate kopirane v odložišče';
@override
String get manualCoordinates => 'Ročne koordinate';
@@ -1933,37 +1366,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get longitudeLabel => 'Geografska dolžina';
@override
String get invalidLatitude => 'Neveljavna geografska širina (-90 do 90)';
@override
String get invalidLongitude => 'Neveljavna geografska dolžina (-180 do 180)';
@override
String get exampleCoordinates => 'Primer: 46.0569, 14.5058';
@override
String get drawingShared => 'Risba deljena';
@override
String get drawingHidden => 'Risba skrita z zemljevida';
@override
String alreadyShared(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count že deljeno',
one: '1 že deljeno',
);
return '$_temp0';
}
@override
String newDrawingsShared(int count, String plural) {
return 'Deljeno $count nov$plural risb$plural';
}
@override
String get shareDrawing => 'Deli risbo';
@@ -2018,12 +1423,6 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get forestRoads => 'Gozdne ceste';
@override
String get showCadastralParcels => 'Prikaži katastrske parcele';
@override
String get showForestRoads => 'Prikaži gozdne ceste';
@override
String get wmsOverlays => 'WMS prekrivanja';
@@ -2126,10 +1525,6 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Neuspešno brisanje kanala: $error';
}
@override
String get allChannelSlotsInUse =>
'Vsa mesta za kanale so zasedena (maksimalno 39 prilagojenih kanalov)';
@override
String get createChannel => 'Ustvari kanal';
@@ -2170,21 +1565,6 @@ class AppLocalizationsSl extends AppLocalizations {
String get wizardConnectingFeature3 =>
'Deluje povsem brez povezave — internet ni potreben';
@override
String get wizardSimpleModeTitle => 'Preprost način';
@override
String get wizardSimpleModeDescription =>
'Ste novi v mesh omrežju? Vključite preprost način za poenostavljen vmesnik z osnovnimi funkcijami.';
@override
String get wizardSimpleModeFeature1 =>
'Vmesnik, prilagojen začetnikom, z osnovnimi funkcijami';
@override
String get wizardSimpleModeFeature2 =>
'Na napredni način lahko kadarkoli preklopite v nastavitvah';
@override
String get wizardChannelTitle => 'Kanali';
@@ -2279,7 +1659,7 @@ class AppLocalizationsSl extends AppLocalizations {
String get delivery => 'Dostava';
@override
String get status => 'Status';
String get status => 'Stanje';
@override
String get expectedAckTag => 'Pričakovana oznaka ACK';

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

2280
lib/l10n/app_pl.arb Normal file

File diff suppressed because it is too large Load Diff

2280
lib/l10n/app_pt.arb Normal file

File diff suppressed because it is too large Load Diff

515
lib/l10n/app_ru.arb Normal file
View File

@@ -0,0 +1,515 @@
{
"@@locale": "ru",
"appTitle": "MeshCore SAR",
"messages": "Сообщения",
"contacts": "Контакты",
"map": "Карта",
"settings": "Настройки",
"connect": "Подключить",
"disconnect": "Отключить",
"noDevicesFound": "Устройства не найдены",
"scanAgain": "Повторить поиск",
"tapToConnect": "Нажмите для подключения",
"deviceNotConnected": "Устройство не подключено",
"locationPermissionDenied": "Доступ к геолокации запрещён",
"locationPermissionPermanentlyDenied": "Доступ к геолокации запрещён навсегда. Включите его в настройках.",
"locationPermissionRequired": "Доступ к геолокации необходим для GPS-трекинга и координации команды. Вы можете включить его позже в настройках.",
"locationServicesDisabled": "Службы геолокации отключены. Пожалуйста, включите их в настройках.",
"failedToGetGpsLocation": "Не удалось получить GPS-координаты",
"failedToAdvertise": "Ошибка трансляции: {error}",
"cancelReconnection": "Отменить переподключение",
"general": "Основные",
"theme": "Тема",
"chooseTheme": "Выбрать тему",
"light": "Светлая",
"dark": "Тёмная",
"blueLightTheme": "Синяя светлая тема",
"blueDarkTheme": "Синяя тёмная тема",
"sarRed": "SAR Красная",
"alertEmergencyMode": "Режим тревоги / ЧС",
"sarGreen": "SAR Зелёная",
"safeAllClearMode": "Режим «Всё в порядке»",
"autoSystem": "Авто (Система)",
"followSystemTheme": "Следовать системной теме",
"showRxTxIndicators": "Показывать индикаторы RX/TX",
"displayPacketActivity": "Отображать активность пакетов в верхней панели",
"disableMap": "Отключить карту",
"disableMapDescription": "Скрыть вкладку карты для экономии заряда батареи",
"language": "Язык",
"chooseLanguage": "Выбрать язык",
"save": "Сохранить",
"cancel": "Отмена",
"close": "Закрыть",
"about": "О приложении",
"appVersion": "Версия приложения",
"appName": "Название приложения",
"aboutMeshCoreSar": "О MeshCore SAR",
"aboutDescription": "Приложение для поисково-спасательных операций, разработанное для аварийно-спасательных служб. Возможности:\n\n• BLE mesh-сеть для связи между устройствами\n• Офлайн-карты с несколькими вариантами слоёв\n• Отслеживание членов команды в реальном времени\n• Тактические маркеры SAR (найденный человек, пожар, место сбора)\n• Управление контактами и обмен сообщениями\n• GPS-трекинг с показанием курса компаса\n• Кэширование тайлов карт для работы офлайн",
"technologiesUsed": "Используемые технологии:",
"technologiesList": "• Flutter для кросс-платформенной разработки\n• BLE (Bluetooth Low Energy) для mesh-сети\n• OpenStreetMap для карт\n• Provider для управления состоянием\n• SharedPreferences для локального хранилища",
"moreInfo": "Подробнее",
"packageName": "Имя пакета",
"sampleData": "Тестовые данные",
"sampleDataDescription": "Загрузить или очистить тестовые контакты, сообщения каналов и маркеры SAR",
"loadSampleData": "Загрузить тестовые данные",
"clearAllData": "Очистить все данные",
"clearAllDataConfirmTitle": "Очистить все данные",
"clearAllDataConfirmMessage": "Это удалит все контакты и маркеры SAR. Вы уверены?",
"clear": "Очистить",
"loadedSampleData": "Загружено: {teamCount} членов команды, {channelCount} каналов, {sarCount} маркеров SAR, {messageCount} сообщений",
"failedToLoadSampleData": "Не удалось загрузить тестовые данные: {error}",
"allDataCleared": "Все данные очищены",
"failedToStartBackgroundTracking": "Не удалось запустить фоновое отслеживание. Проверьте разрешения и BLE-соединение.",
"locationBroadcast": "Трансляция геолокации: {latitude}, {longitude}",
"defaultPinInfo": "PIN-код по умолчанию для устройств без экрана — 123456. Проблемы с сопряжением? Удалите устройство из Bluetooth в системных настройках.",
"noMessagesYet": "Сообщений пока нет",
"pullDownToSync": "Потяните вниз для синхронизации сообщений",
"deleteContact": "Удалить контакт",
"delete": "Удалить",
"viewOnMap": "Показать на карте",
"refresh": "Обновить",
"resetPath": "Сбросить маршрут (перепроложить)",
"publicKeyCopied": "Публичный ключ скопирован в буфер обмена",
"copiedToClipboard": "{label} скопировано в буфер обмена",
"pleaseEnterPassword": "Пожалуйста, введите пароль",
"failedToSyncContacts": "Не удалось синхронизировать контакты: {error}",
"loggedInSuccessfully": "Вход выполнен! Ожидание сообщений комнаты...",
"loginFailed": "Ошибка входа — неверный пароль",
"loggingIn": "Вход в {roomName}...",
"failedToSendLogin": "Не удалось отправить данные входа: {error}",
"lowLocationAccuracy": "Низкая точность геолокации",
"continue_": "Продолжить",
"sendSarMarker": "Отправить маркер SAR",
"deleteDrawing": "Удалить рисунок",
"drawingTools": "Инструменты рисования",
"drawLine": "Нарисовать линию",
"drawLineDesc": "Нарисуйте произвольную линию на карте",
"drawRectangle": "Нарисовать прямоугольник",
"drawRectangleDesc": "Нарисуйте прямоугольную область на карте",
"measureDistance": "Измерить расстояние",
"measureDistanceDesc": "Долгое нажатие на две точки для измерения",
"clearMeasurement": "Сбросить измерение",
"distanceLabel": "Расстояние: {distance}",
"longPressForSecondPoint": "Долгое нажатие для второй точки",
"longPressToStartMeasurement": "Долгое нажатие для установки первой точки",
"longPressToStartNewMeasurement": "Долгое нажатие для начала нового измерения",
"shareDrawings": "Поделиться рисунками",
"clearAllDrawings": "Удалить все рисунки",
"completeLine": "Завершить линию",
"broadcastDrawingsToTeam": "Транслировать {count} рисун{plural} команде",
"removeAllDrawings": "Удалить все {count} рисун{plural}",
"deleteAllDrawingsConfirm": "Удалить все {count} рисун{plural} с карты?",
"drawing": "Рисунок",
"shareDrawingsCount": "Поделиться {count} рисун{plural}",
"showReceivedDrawings": "Показать полученные рисунки",
"showingAllDrawings": "Показаны все рисунки",
"showingOnlyYourDrawings": "Показаны только ваши рисунки",
"showSarMarkers": "Показать маркеры SAR",
"showingSarMarkers": "Маркеры SAR отображаются",
"hidingSarMarkers": "Маркеры SAR скрыты",
"clearAll": "Очистить всё",
"publicChannel": "Публичный канал",
"broadcastToAll": "Трансляция всем ближайшим узлам (временно)",
"storedPermanently": "Сохранено постоянно в комнате",
"notConnectedToDevice": "Устройство не подключено",
"typeYourMessage": "Введите сообщение...",
"quickLocationMarker": "Быстрый маркер местоположения",
"markerType": "Тип маркера",
"sendTo": "Отправить в",
"noDestinationsAvailable": "Нет доступных получателей.",
"selectDestination": "Выберите получателя...",
"ephemeralBroadcastInfo": "Временно: передаётся по эфиру. Не сохраняется — узлы должны быть онлайн.",
"persistentRoomInfo": "Постоянно: хранится неизменно в комнате. Синхронизируется автоматически и доступно офлайн.",
"location": "Местоположение",
"fromMap": "С карты",
"gettingLocation": "Получение местоположения...",
"locationError": "Ошибка геолокации",
"retry": "Повторить",
"refreshLocation": "Обновить местоположение",
"accuracyMeters": "Точность: ±{accuracy}м",
"notesOptional": "Заметки (необязательно)",
"addAdditionalInformation": "Добавьте дополнительную информацию...",
"lowAccuracyWarning": "Точность геолокации: ±{accuracy}м. Этого может быть недостаточно для операций SAR.\n\nПродолжить всё равно?",
"loginToRoom": "Войти в комнату",
"enterPasswordInfo": "Введите пароль для доступа к этой комнате. Пароль будет сохранён для дальнейшего использования.",
"password": "Пароль",
"enterRoomPassword": "Введите пароль комнаты",
"loggingInDots": "Вход...",
"login": "Войти",
"failedToAddRoom": "Не удалось добавить комнату на устройство: {error}\n\nВозможно, комната ещё не объявила себя.\nПопробуйте подождать, пока комната не выйдет на связь.",
"direct": "Напрямую",
"flood": "Широковещательно",
"loggedIn": "Вход выполнен",
"noGpsData": "Нет данных GPS",
"distance": "Расстояние",
"directPingTimeout": "Таймаут прямого пинга — повтор {name} широковещательно...",
"pingFailed": "Пинг не удался: {name} — ответ не получен",
"deleteContactConfirmation": "Вы уверены, что хотите удалить \"{name}\"?\n\nЭто удалит контакт как из приложения, так и с сопряжённого радиоустройства.",
"failedToRemoveContact": "Не удалось удалить контакт: {error}",
"type": "Тип",
"publicKey": "Публичный ключ",
"lastSeen": "Последнее появление",
"roomStatus": "Статус комнаты",
"loginStatus": "Статус входа",
"notLoggedIn": "Не выполнен вход",
"adminAccess": "Доступ администратора",
"yes": "Да",
"no": "Нет",
"permissions": "Разрешения",
"passwordSaved": "Пароль сохранён",
"locationColon": "Местоположение:",
"telemetry": "Телеметрия",
"voltage": "Напряжение",
"battery": "Батарея",
"temperature": "Температура",
"humidity": "Влажность",
"pressure": "Давление",
"gpsTelemetry": "GPS (телеметрия)",
"updated": "Обновлено",
"pathResetInfo": "Маршрут сброшен для {name}. Следующее сообщение найдёт новый путь.",
"reLoginToRoom": "Войти в комнату повторно",
"heading": "Курс",
"elevation": "Высота",
"accuracy": "Точность",
"bearing": "Азимут",
"direction": "Направление",
"filterMarkers": "Фильтр маркеров",
"filterMarkersTooltip": "Фильтровать маркеры",
"contactsFilter": "Контакты",
"repeatersFilter": "Ретрансляторы",
"sarMarkers": "Маркеры SAR",
"foundPerson": "Найденный человек",
"fire": "Пожар",
"stagingArea": "Место сбора",
"showAll": "Показать все",
"locationUnavailable": "Местоположение недоступно",
"ahead": "впереди",
"degreesRight": "{degrees}° вправо",
"degreesLeft": "{degrees}° влево",
"latLonFormat": "Ш: {latitude} Д: {longitude}",
"noContactsYet": "Контактов пока нет",
"connectToDeviceToLoadContacts": "Подключите устройство для загрузки контактов",
"teamMembers": "Члены команды",
"repeaters": "Ретрансляторы",
"rooms": "Комнаты",
"channels": "Каналы",
"selectMapLayer": "Выбрать слой карты",
"openStreetMap": "OpenStreetMap",
"openTopoMap": "OpenTopoMap",
"esriSatellite": "ESRI Спутник",
"googleHybrid": "Google Гибрид",
"googleRoadmap": "Google Дороги",
"googleTerrain": "Google Рельеф",
"dragToPosition": "Перетащите для позиционирования",
"createSarMarker": "Создать маркер SAR",
"compass": "Компас",
"navigationAndContacts": "Навигация и контакты",
"sarAlert": "ТРЕВОГА SAR",
"textCopiedToClipboard": "Текст скопирован в буфер обмена",
"cannotReplySenderMissing": "Не удаётся ответить: нет информации об отправителе",
"cannotReplyContactNotFound": "Не удаётся ответить: контакт не найден",
"copyText": "Копировать текст",
"saveAsTemplate": "Сохранить как шаблон",
"templateSaved": "Шаблон успешно сохранён",
"templateAlreadyExists": "Шаблон с таким эмодзи уже существует",
"deleteMessage": "Удалить сообщение",
"deleteMessageConfirmation": "Вы уверены, что хотите удалить это сообщение?",
"shareLocation": "Поделиться местоположением",
"shareLocationText": "{markerInfo}\n\nКоординаты: {lat}, {lon}\n\nGoogle Maps: {url}",
"sarLocationShare": "Местоположение SAR",
"justNow": "Только что",
"minutesAgo": "{minutes}м назад",
"hoursAgo": "{hours}ч назад",
"daysAgo": "{days}д назад",
"secondsAgo": "{seconds}с назад",
"sending": "Отправка...",
"sent": "Отправлено",
"delivered": "Доставлено",
"deliveredWithTime": "Доставлено ({time}мс)",
"failed": "Ошибка",
"broadcast": "Трансляция",
"deliveredToContacts": "Доставлено {delivered}/{total} контактам",
"allDelivered": "Все доставлено",
"recipientDetails": "Детали получателей",
"pending": "Ожидание",
"sarMarkerFoundPerson": "Найденный человек",
"sarMarkerFire": "Место пожара",
"sarMarkerStagingArea": "Место сбора",
"sarMarkerObject": "Найденный объект",
"from": "От",
"coordinates": "Координаты",
"tapToViewOnMap": "Нажмите, чтобы открыть на карте",
"radioSettings": "Настройки радио",
"frequencyMHz": "Частота (МГц)",
"frequencyExample": "например, 869.618",
"bandwidth": "Полоса пропускания",
"spreadingFactor": "Коэффициент расширения",
"codingRate": "Скорость кодирования",
"txPowerDbm": "Мощность TX (дБм)",
"maxPowerDbm": "Макс: {power} дБм",
"you": "Вы",
"exportFailed": "Ошибка экспорта: {error}",
"importFailed": "Ошибка импорта: {error}",
"unknown": "Неизвестно",
"onlineLayers": "Онлайн-слои",
"locationTrail": "Трек местоположения",
"showTrailOnMap": "Показать трек на карте",
"trailVisible": "Трек отображается на карте",
"trailHiddenRecording": "Трек скрыт (запись продолжается)",
"duration": "Продолжительность",
"points": "Точки",
"clearTrail": "Очистить трек",
"clearTrailQuestion": "Очистить трек?",
"clearTrailConfirmation": "Вы уверены, что хотите очистить текущий трек? Это действие нельзя отменить.",
"noTrailRecorded": "Трек ещё не записан",
"startTrackingToRecord": "Запустите отслеживание геолокации для записи трека",
"trailControls": "Управление треком",
"contactTrails": "Треки контактов",
"showAllContactTrails": "Показать все треки контактов",
"noContactsWithLocationHistory": "Нет контактов с историей местоположения",
"showingTrailsForContacts": "Показаны треки для {count} контактов",
"individualContactTrails": "Индивидуальные треки контактов",
"deviceInformation": "Информация об устройстве",
"bleName": "Имя BLE",
"meshName": "Имя в сети Mesh",
"notSet": "Не задано",
"model": "Модель",
"version": "Версия",
"buildDate": "Дата сборки",
"firmware": "Прошивка",
"maxContacts": "Макс. контактов",
"maxChannels": "Макс. каналов",
"publicInfo": "Публичная информация",
"meshNetworkName": "Название сети Mesh",
"nameBroadcastInMesh": "Имя, транслируемое в mesh-объявлениях",
"telemetryAndLocationSharing": "Телеметрия и передача местоположения",
"lat": "Ш",
"lon": "Д",
"useCurrentLocation": "Использовать текущее местоположение",
"noneUnknown": "Нет/Неизвестно",
"chatNode": "Узел чата",
"repeater": "Ретранслятор",
"roomChannel": "Комната/Канал",
"typeNumber": "Тип {number}",
"copiedToClipboardShort": "{label} скопировано в буфер обмена",
"failedToSave": "Не удалось сохранить: {error}",
"failedToGetLocation": "Не удалось получить местоположение: {error}",
"sarTemplates": "Шаблоны SAR",
"manageSarTemplates": "Управление шаблонами целеуказания",
"addTemplate": "Добавить шаблон",
"editTemplate": "Изменить шаблон",
"deleteTemplate": "Удалить шаблон",
"templateName": "Название шаблона",
"templateNameHint": "например, Найденный человек",
"templateEmoji": "Эмодзи",
"emojiRequired": "Требуется эмодзи",
"nameRequired": "Требуется название",
"templateDescription": "Описание (необязательно)",
"templateDescriptionHint": "Добавьте дополнительный контекст...",
"templateColor": "Цвет",
"previewFormat": "Предпросмотр (формат сообщения SAR)",
"importFromClipboard": "Импорт",
"exportToClipboard": "Экспорт",
"deleteTemplateConfirmation": "Удалить шаблон '{name}'?",
"templateAdded": "Шаблон добавлен",
"templateUpdated": "Шаблон обновлён",
"templateDeleted": "Шаблон удалён",
"templatesImported": "{count, plural, =0{Шаблоны не импортированы} =1{Импортирован 1 шаблон} other{Импортировано {count} шаблонов}}",
"templatesExported": "{count, plural, =1{1 шаблон экспортирован в буфер обмена} other{{count} шаблонов экспортировано в буфер обмена}}",
"resetToDefaults": "Сбросить до умолчаний",
"resetToDefaultsConfirmation": "Все пользовательские шаблоны будут удалены и восстановлены 4 шаблона по умолчанию. Продолжить?",
"reset": "Сбросить",
"resetComplete": "Шаблоны сброшены до умолчаний",
"noTemplates": "Шаблоны недоступны",
"tapAddToCreate": "Нажмите +, чтобы создать первый шаблон",
"ok": "ОК",
"permissionsSection": "Разрешения",
"locationPermission": "Разрешение геолокации",
"checking": "Проверка...",
"locationPermissionGrantedAlways": "Разрешено (всегда)",
"locationPermissionGrantedWhileInUse": "Разрешено (при использовании)",
"locationPermissionDeniedTapToRequest": "Запрещено — нажмите для запроса",
"locationPermissionPermanentlyDeniedOpenSettings": "Запрещено навсегда — откройте настройки",
"locationPermissionDialogContent": "Доступ к геолокации запрещён навсегда. Включите его в настройках устройства для GPS-трекинга и обмена местоположением.",
"openSettings": "Открыть настройки",
"locationPermissionGranted": "Разрешение геолокации получено!",
"locationPermissionRequiredForGps": "Разрешение геолокации необходимо для GPS-трекинга и обмена местоположением.",
"locationPermissionAlreadyGranted": "Разрешение геолокации уже предоставлено.",
"sarNavyBlue": "SAR Тёмно-синяя",
"sarNavyBlueDescription": "Профессиональный / оперативный режим",
"selectRecipient": "Выбрать получателя",
"broadcastToAllNearby": "Трансляция всем ближайшим",
"searchRecipients": "Поиск получателей...",
"noContactsFound": "Контакты не найдены",
"noRoomsFound": "Комнаты не найдены",
"noRecipientsAvailable": "Нет доступных получателей",
"noChannelsFound": "Каналы не найдены",
"newMessage": "Новое сообщение",
"channel": "Канал",
"samplePoliceLead": "Руководитель группы полиции",
"sampleDroneOperator": "Оператор дрона",
"sampleFirefighterAlpha": "Пожарный",
"sampleMedicCharlie": "Медик",
"sampleCommandDelta": "Командование",
"sampleFireEngine": "Пожарная машина",
"sampleAirSupport": "Авиационная поддержка",
"sampleBaseCoordinator": "Базовый координатор",
"channelEmergency": "Аварийный",
"channelCoordination": "Координация",
"channelUpdates": "Обновления",
"sampleTeamMember": "Тестовый член команды",
"sampleScout": "Тестовый разведчик",
"sampleBase": "Тестовая база",
"sampleSearcher": "Тестовый поисковик",
"sampleObjectBackpack": " Найден рюкзак синего цвета",
"sampleObjectVehicle": " Брошенный автомобиль — установить владельца",
"sampleObjectCamping": " Обнаружено туристическое снаряжение",
"sampleObjectTrailMarker": " Указатель тропы найден вне маршрута",
"sampleMsgAllTeamsCheckIn": "Всем группам: сообщить о готовности",
"sampleMsgWeatherUpdate": "Погода: ясно, температура 18°C",
"sampleMsgBaseCamp": "Базовый лагерь развёрнут у места сбора",
"sampleMsgTeamAlpha": "Группа выдвигается в сектор 2",
"sampleMsgRadioCheck": "Проверка связи — всем станциям ответить",
"sampleMsgWaterSupply": "Вода доступна на контрольной точке 3",
"sampleMsgTeamBravo": "Группа докладывает: сектор 1 чист",
"sampleMsgEtaRallyPoint": "Прибытие на место сбора: 15 минут",
"sampleMsgSupplyDrop": "Сброс снаряжения подтверждён на 14:00",
"sampleMsgDroneSurvey": "Разведка дроном завершена — ничего не обнаружено",
"sampleMsgTeamCharlie": "Группа запрашивает подкрепление",
"sampleMsgRadioDiscipline": "Всем подразделениям: соблюдать радиодисциплину",
"sampleMsgUrgentMedical": "СРОЧНО: нужна медицинская помощь в секторе 4",
"sampleMsgAdultMale": " Взрослый мужчина, в сознании",
"sampleMsgFireSpotted": "Обнаружен пожар — координаты следуют",
"sampleMsgSpreadingRapidly": " Распространяется быстро!",
"sampleMsgPriorityHelicopter": "ПРИОРИТЕТ: нужна поддержка вертолёта",
"sampleMsgMedicalTeamEnRoute": "Медицинская группа движется к вашему местоположению",
"sampleMsgEvacHelicopter": "Вертолёт эвакуации: прибытие через 10 минут",
"sampleMsgEmergencyResolved": "Чрезвычайная ситуация ликвидирована — опасности нет",
"sampleMsgEmergencyStagingArea": " Аварийное место сбора",
"sampleMsgEmergencyServices": "Аварийные службы уведомлены и реагируют",
"sampleAlphaTeamLead": "Руководитель группы",
"sampleBravoScout": "Разведчик",
"sampleCharlieMedic": "Медик",
"sampleDeltaNavigator": "Навигатор",
"sampleEchoSupport": "Поддержка",
"sampleBaseCommand": "Базовое командование",
"sampleFieldCoordinator": "Полевой координатор",
"sampleMedicalTeam": "Медицинская группа",
"mapDrawing": "Рисунок на карте",
"navigateToDrawing": "Перейти к рисунку",
"copyCoordinates": "Копировать координаты",
"hideFromMap": "Скрыть с карты",
"lineDrawing": "Линия",
"rectangleDrawing": "Прямоугольник",
"manualCoordinates": "Ввод координат вручную",
"enterCoordinatesManually": "Ввести координаты вручную",
"latitudeLabel": "Широта",
"longitudeLabel": "Долгота",
"exampleCoordinates": "Пример: 55.7558, 37.6173",
"shareDrawing": "Поделиться рисунком",
"shareWithAllNearbyDevices": "Поделиться со всеми ближайшими устройствами",
"shareToRoom": "Отправить в комнату",
"sendToPersistentStorage": "Отправить в постоянное хранилище комнаты",
"deleteDrawingConfirm": "Вы уверены, что хотите удалить этот рисунок?",
"drawingDeleted": "Рисунок удалён",
"yourDrawingsCount": "Ваши рисунки ({count})",
"shared": "Передано",
"line": "Линия",
"rectangle": "Прямоугольник",
"updateAvailable": "Доступно обновление",
"currentVersion": "Текущая",
"latestVersion": "Последняя",
"downloadUpdate": "Скачать",
"updateLater": "Позже",
"cadastralParcels": "Кадастровые участки",
"forestRoads": "Лесные дороги",
"wmsOverlays": "WMS-наложения",
"hikingTrails": "Туристические маршруты",
"mainRoads": "Главные дороги",
"houseNumbers": "Номера домов",
"fireHazardZones": "Пожароопасные зоны",
"historicalFires": "Исторические пожары",
"firebreaks": "Противопожарные просеки",
"krasFireZones": "Пожарные зоны Краса",
"placeNames": "Названия мест",
"municipalityBorders": "Границы муниципалитетов",
"topographicMap": "Топографическая карта 1:25000",
"recentMessages": "Последние сообщения",
"addChannel": "Добавить канал",
"channelName": "Название канала",
"channelNameHint": "например, Группа спасения Альфа",
"channelSecret": "Секрет канала",
"channelSecretHint": "Общий пароль для этого канала",
"channelSecretHelp": "Этот секрет необходимо передать всем членам команды, которым нужен доступ к каналу",
"channelTypesInfo": "Hash-каналы (#команда): секрет генерируется из названия автоматически. Одно название = один канал на всех устройствах.\n\nЗакрытые каналы: используется явный секрет. Войти могут только те, у кого есть секрет.",
"hashChannelInfo": "Hash-канал: секрет будет автоматически создан из названия. Все, кто использует одно и то же имя, окажутся в одном канале.",
"channelNameRequired": "Требуется название канала",
"channelNameTooLong": "Название канала не должно превышать 31 символ",
"channelSecretRequired": "Требуется секрет канала",
"channelSecretTooLong": "Секрет канала не должен превышать 32 символа",
"invalidAsciiCharacters": "Разрешены только символы ASCII",
"channelCreatedSuccessfully": "Канал успешно создан",
"channelCreationFailed": "Не удалось создать канал: {error}",
"deleteChannel": "Удалить канал",
"deleteChannelConfirmation": "Вы уверены, что хотите удалить канал \"{channelName}\"? Это действие нельзя отменить.",
"channelDeletedSuccessfully": "Канал успешно удалён",
"channelDeletionFailed": "Не удалось удалить канал: {error}",
"createChannel": "Создать канал",
"wizardBack": "Назад",
"wizardSkip": "Пропустить",
"wizardNext": "Далее",
"wizardGetStarted": "Начать",
"wizardWelcomeTitle": "Добро пожаловать в MeshCore SAR",
"wizardWelcomeDescription": "Мощный инструмент связи вне сети для поисково-спасательных операций. Поддерживайте связь с командой через mesh-радио, когда традиционные сети недоступны.",
"wizardConnectingTitle": "Подключение к радиоустройству",
"wizardConnectingDescription": "Подключите смартфон к радиоустройству MeshCore по Bluetooth для связи вне сети.",
"wizardConnectingFeature1": "Поиск ближайших устройств MeshCore",
"wizardConnectingFeature2": "Сопряжение с радиоустройством по Bluetooth",
"wizardConnectingFeature3": "Работает полностью офлайн — интернет не нужен",
"wizardChannelTitle": "Каналы",
"wizardChannelDescription": "Транслируйте сообщения всем участникам канала — идеально для общих объявлений и координации команды.",
"wizardChannelFeature1": "Публичный канал для общей связи команды",
"wizardChannelFeature2": "Создавайте пользовательские каналы для отдельных групп",
"wizardChannelFeature3": "Сообщения автоматически ретранслируются через mesh",
"wizardContactsTitle": "Контакты",
"wizardContactsDescription": "Члены вашей команды появляются автоматически по мере подключения к mesh-сети. Отправляйте им личные сообщения или просматривайте их местоположение.",
"wizardContactsFeature1": "Контакты обнаруживаются автоматически",
"wizardContactsFeature2": "Отправка личных сообщений",
"wizardContactsFeature3": "Просмотр уровня заряда и времени последнего появления",
"wizardMapTitle": "Карта и местоположение",
"wizardMapDescription": "Отслеживайте команду в реальном времени и отмечайте важные места для поисково-спасательных операций.",
"wizardMapFeature1": "Маркеры SAR для найденных людей, пожаров и мест сбора",
"wizardMapFeature2": "GPS-отслеживание членов команды в реальном времени",
"wizardMapFeature3": "Загрузка офлайн-карт для отдалённых районов",
"wizardMapFeature4": "Рисование фигур и обмен тактической информацией",
"viewWelcomeTutorial": "Просмотреть обучение",
"allTeamContacts": "Все контакты команды",
"directMessagesInfo": "Личные сообщения с подтверждением. Отправлено {count} членам команды.",
"sarMarkerSentToContacts": "Маркер SAR отправлен {count} контактам",
"noContactsAvailable": "Нет доступных контактов команды",
"reply": "Ответить",
"technicalDetails": "Технические детали",
"messageTechnicalDetails": "Технические детали сообщения",
"linkQuality": "Качество связи",
"delivery": "Доставка",
"status": "Статус",
"expectedAckTag": "Ожидаемый тег ACK",
"roundTrip": "Время отклика",
"retryAttempt": "Попытка повтора",
"floodFallback": "Широковещательный резерв",
"identity": "Идентификатор",
"messageId": "ID сообщения",
"sender": "Отправитель",
"senderKey": "Ключ отправителя",
"recipient": "Получатель",
"recipientKey": "Ключ получателя",
"voice": "Голос",
"voiceId": "ID голоса",
"envelope": "Конверт",
"sessionProgress": "Прогресс сессии",
"complete": "Завершено",
"rawDump": "Необработанные данные",
"cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе",
"voiceUnavailable": "Голос сейчас недоступен",
"requestingVoice": "Запрос голоса"
}

File diff suppressed because it is too large Load Diff

2280
lib/l10n/app_tr.arb Normal file

File diff suppressed because it is too large Load Diff

2280
lib/l10n/app_uk.arb Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
import 'dart:io' show Platform;
import 'dart:async';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart';
@@ -14,11 +17,12 @@ import 'providers/channels_provider.dart';
import 'providers/voice_provider.dart';
import 'providers/image_provider.dart' as ip;
import 'providers/app_provider.dart';
import 'providers/sensors_provider.dart';
import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart';
import 'services/tile_cache_service.dart';
import 'services/notification_service.dart';
import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart';
import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart';
import 'screens/home_screen.dart';
@@ -70,6 +74,9 @@ 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;
@@ -158,7 +165,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;
}
@@ -234,6 +241,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
},
),
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()),
// Voice provider (packet reassembly + playback)
ChangeNotifierProvider(
@@ -246,18 +254,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Image provider (fragment reassembly + outgoing session cache)
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
// Tile cache service
Provider(create: (_) => TileCacheService()),
// App provider that coordinates everything
// VoiceProvider is read via context.read inside create since it's already registered above
ChangeNotifierProxyProvider6<
ChangeNotifierProxyProvider5<
ConnectionProvider,
ContactsProvider,
MessagesProvider,
DrawingProvider,
ChannelsProvider,
TileCacheService,
AppProvider
>(
create: (context) => AppProvider(
@@ -268,7 +272,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
channelsProvider: context.read<ChannelsProvider>(),
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
tileCacheService: context.read<TileCacheService>(),
),
update:
(
@@ -278,7 +281,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
messages,
drawings,
channels,
tileCache,
previous,
) =>
previous ??
@@ -290,7 +292,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
channelsProvider: channels,
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
tileCacheService: tileCache,
),
),
],
@@ -330,7 +331,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

@@ -1,10 +1,186 @@
export 'package:meshcore_client/meshcore_client.dart'
show Contact, ContactType, ContactTelemetry, AdvertLocation;
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import 'package:meshcore_client/meshcore_client.dart';
class ParsedContactRoute {
final String canonicalText;
final int hashSize;
final int hopCount;
final int encodedPathLen;
final int signedEncodedPathLen;
final Uint8List pathBytes;
final Uint8List paddedPathBytes;
const ParsedContactRoute({
required this.canonicalText,
required this.hashSize,
required this.hopCount,
required this.encodedPathLen,
required this.signedEncodedPathLen,
required this.pathBytes,
required this.paddedPathBytes,
});
int get byteLength => pathBytes.length;
String get summary => hopCount == 0
? 'Direct'
: '$hopCount hop${hopCount == 1 ? '' : 's'} via $hashSize-byte hashes';
}
class ContactRouteFormatException implements Exception {
final String message;
const ContactRouteFormatException(this.message);
@override
String toString() => message;
}
class ContactRouteCodec {
static const int maxHashSize = 3;
static const int maxPathBytes = 64;
static const int _unknownDescriptor = 0xFF;
static ParsedContactRoute parse(String input, {int? expectedHashSize}) {
final normalized = input.trim().toUpperCase();
if (normalized.isEmpty) {
throw const ContactRouteFormatException('Route cannot be empty.');
}
if (expectedHashSize != null &&
(expectedHashSize < 1 || expectedHashSize > maxHashSize)) {
throw const ContactRouteFormatException(
'Hash size must be 1, 2, or 3 bytes.',
);
}
final hopTokens = normalized
.split(',')
.map((token) => token.trim())
.toList();
if (hopTokens.any((token) => token.isEmpty)) {
throw const ContactRouteFormatException('Route contains an empty hop.');
}
final hopBytes = <List<int>>[];
int? hashSize;
for (final token in hopTokens) {
final compact = token.replaceAll(':', '');
if (compact.isEmpty || !RegExp(r'^[0-9A-F]+$').hasMatch(compact)) {
throw ContactRouteFormatException('Invalid hop "$token".');
}
if (compact.length.isOdd) {
throw ContactRouteFormatException(
'Hop "$token" must contain full bytes.',
);
}
final currentHashSize = compact.length ~/ 2;
if (currentHashSize < 1 || currentHashSize > maxHashSize) {
throw ContactRouteFormatException(
'Hop "$token" must be 1, 2, or 3 bytes.',
);
}
if (expectedHashSize != null && currentHashSize != expectedHashSize) {
throw ContactRouteFormatException(
'Hop "$token" must be $expectedHashSize '
'byte${expectedHashSize == 1 ? '' : 's'}.',
);
}
hashSize ??= currentHashSize;
if (hashSize != currentHashSize) {
throw const ContactRouteFormatException(
'All hops in a route must use the same hash size.',
);
}
final bytes = <int>[];
for (var i = 0; i < compact.length; i += 2) {
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
}
hopBytes.add(bytes);
}
final resolvedHashSize = hashSize ?? 1;
final flatBytes = Uint8List.fromList(
hopBytes.expand((hop) => hop).toList(),
);
if (flatBytes.length > maxPathBytes) {
throw const ContactRouteFormatException(
'Route exceeds the 64-byte firmware limit.',
);
}
final encodedPathLen =
((resolvedHashSize - 1) << 6) | (hopBytes.length & 0x3F);
final padded = Uint8List(maxPathBytes);
padded.setRange(0, flatBytes.length, flatBytes);
return ParsedContactRoute(
canonicalText: hopBytes
.map(
(hop) => hop
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
)
.join(','),
hashSize: resolvedHashSize,
hopCount: hopBytes.length,
encodedPathLen: encodedPathLen,
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
pathBytes: flatBytes,
paddedPathBytes: padded,
);
}
static ParsedContactRoute? fromContact(Contact contact) {
if (!contact.routeHasPath || contact.routeHopCount == 0) {
return null;
}
return ParsedContactRoute(
canonicalText: contact.routeCanonicalText,
hashSize: contact.routeHashSize,
hopCount: contact.routeHopCount,
encodedPathLen: contact.routeEncodedPathLen,
signedEncodedPathLen: contact.routeSignedPathLen,
pathBytes: contact.routePathBytes,
paddedPathBytes: _padPath(contact.routePathBytes),
);
}
static Uint8List _padPath(Uint8List bytes) {
final padded = Uint8List(maxPathBytes);
padded.setRange(0, math.min(bytes.length, maxPathBytes), bytes);
return padded;
}
static int toSignedDescriptor(int encodedPathLen) =>
encodedPathLen > 127 ? encodedPathLen - 256 : encodedPathLen;
static int toUnsignedDescriptor(int signedPathLen) => signedPathLen & 0xFF;
static bool isUnknownDescriptor(int signedPathLen) =>
toUnsignedDescriptor(signedPathLen) == _unknownDescriptor;
static bool isValidDescriptor(int signedPathLen) {
final raw = toUnsignedDescriptor(signedPathLen);
if (raw == _unknownDescriptor) return false;
final hashSize = ((raw >> 6) + 1);
if (hashSize > maxHashSize) return false;
final hopCount = raw & 0x3F;
return hopCount * hashSize <= maxPathBytes;
}
}
extension ContactLocalization on Contact {
/// Returns the localized display name for special contacts (e.g. Public Channel).
/// For all other contacts, returns [displayName].
@@ -14,4 +190,55 @@ extension ContactLocalization on Contact {
}
return displayName;
}
int get routeEncodedPathLen =>
ContactRouteCodec.toUnsignedDescriptor(outPathLen);
int get routeSignedPathLen =>
ContactRouteCodec.toSignedDescriptor(routeEncodedPathLen);
bool get routeIsUnknown => ContactRouteCodec.isUnknownDescriptor(outPathLen);
bool get routeHasPath =>
!routeIsUnknown && ContactRouteCodec.isValidDescriptor(outPathLen);
int get routeHashSize => routeHasPath ? ((routeEncodedPathLen >> 6) + 1) : 1;
int get routeHopCount => routeHasPath ? (routeEncodedPathLen & 0x3F) : -1;
int get routeByteLength => routeHasPath
? math.min(routeHopCount * routeHashSize, outPath.length)
: 0;
Uint8List get routePathBytes => routeByteLength <= 0
? Uint8List(0)
: Uint8List.fromList(outPath.sublist(0, routeByteLength));
String get routeCanonicalText {
if (!routeHasPath || routeHopCount <= 0) return '';
final bytes = routePathBytes;
final hops = <String>[];
for (var i = 0; i < bytes.length; i += routeHashSize) {
hops.add(
bytes
.sublist(i, i + routeHashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
String get routeSummary {
if (routeIsUnknown) {
return 'Flood/Unknown';
}
if (!routeHasPath || routeHopCount == 0) {
return 'Direct';
}
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
}
bool get routeSupportsLegacyRawTransport => routeHasPath;
}

View File

@@ -73,12 +73,14 @@ class DeviceInfo {
final String? selfName;
// Additional device capabilities (from RESP_CODE_DEVICE_INFO)
final int? maxContacts; // Max contacts device supports
final int? maxChannels; // Max channels device supports
final int? telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
final int? blePin; // BLE PIN code
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
final int? maxContacts; // Max contacts device supports
final int? maxChannels; // Max channels device supports
final int?
telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
final int? blePin; // BLE PIN code
final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
final int?
advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
// Firmware info
final int? firmwareVersion;
@@ -88,6 +90,9 @@ class DeviceInfo {
// Repeat mode (firmware v9+)
final bool? clientRepeat;
final bool? supportsSpectrumScan;
final int? spectrumScanMinKhz;
final int? spectrumScanMaxKhz;
final List<({int lower, int upper})>? allowedRepeatFreqRanges;
DeviceInfo({
@@ -124,6 +129,9 @@ class DeviceInfo {
this.manufacturerModel,
this.semanticVersion,
this.clientRepeat,
this.supportsSpectrumScan,
this.spectrumScanMinKhz,
this.spectrumScanMaxKhz,
this.allowedRepeatFreqRanges,
});
@@ -160,7 +168,9 @@ class DeviceInfo {
/// Get storage usage percentage (0-100)
double? get storageUsedPercent {
if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) {
if (storageUsedKb == null ||
storageTotalKb == null ||
storageTotalKb == 0) {
return null;
}
return (storageUsedKb! / storageTotalKb!) * 100.0;
@@ -245,6 +255,9 @@ class DeviceInfo {
String? manufacturerModel,
String? semanticVersion,
bool? clientRepeat,
bool? supportsSpectrumScan,
int? spectrumScanMinKhz,
int? spectrumScanMaxKhz,
List<({int lower, int upper})>? allowedRepeatFreqRanges,
}) {
return DeviceInfo(
@@ -281,7 +294,11 @@ class DeviceInfo {
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
semanticVersion: semanticVersion ?? this.semanticVersion,
clientRepeat: clientRepeat ?? this.clientRepeat,
allowedRepeatFreqRanges: allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
allowedRepeatFreqRanges:
allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
);
}

View File

@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import '../l10n/app_localizations.dart';
@@ -10,6 +9,7 @@ enum MapLayerType {
googleHybrid,
googleRoadmap,
googleTerrain,
// Kept for stored preference compatibility after MBTiles removal.
vectorMbtiles,
wmsBase,
}
@@ -21,13 +21,6 @@ class MapLayer {
final String attribution;
final double maxZoom;
// Vector tile specific properties
final bool isVector;
final File? mbtilesFile;
final String? styleUrl;
final String? sourceName;
final bool? isGzipped;
// WMS specific properties
final bool isWms;
final String? wmsBaseUrl;
@@ -43,11 +36,6 @@ class MapLayer {
required this.urlTemplate,
required this.attribution,
required this.maxZoom,
this.isVector = false,
this.mbtilesFile,
this.styleUrl,
this.sourceName,
this.isGzipped,
this.isWms = false,
this.wmsBaseUrl,
this.wmsLayers,
@@ -74,7 +62,7 @@ class MapLayer {
case MapLayerType.googleTerrain:
return localizations.googleTerrain;
case MapLayerType.vectorMbtiles:
// For vector tiles, use the name from metadata
// Legacy value kept only for preference migration compatibility.
return name;
case MapLayerType.wmsBase:
// For WMS layers, use the name (will be localized separately)
@@ -182,28 +170,4 @@ class MapLayer {
static MapLayer fromType(MapLayerType type) {
return allLayers.firstWhere((layer) => layer.type == type);
}
/// Create a MapLayer from an MBTiles file
static MapLayer fromMbtilesFile({
required String name,
required File mbtilesFile,
required String styleUrl,
required String sourceName,
required double maxZoom,
required bool isGzipped,
String? attribution,
}) {
return MapLayer(
type: MapLayerType.vectorMbtiles,
name: name,
urlTemplate: '', // Not used for vector tiles
attribution: attribution ?? 'MBTiles',
maxZoom: maxZoom,
isVector: true,
mbtilesFile: mbtilesFile,
styleUrl: styleUrl,
sourceName: sourceName,
isGzipped: isGzipped,
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:latlong2/latlong.dart';
class MessageContactLocation {
final LatLng location;
final String source;
final DateTime capturedAt;
final DateTime? sourceTimestamp;
const MessageContactLocation({
required this.location,
required this.source,
required this.capturedAt,
this.sourceTimestamp,
});
String get technicalSourceLabel {
switch (source) {
case 'telemetry':
return 'telemetry';
case 'advert':
return 'advert';
default:
return source;
}
}
String get formattedCoordinates =>
'${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}';
Map<String, dynamic> toJson() {
return {
'latitude': location.latitude,
'longitude': location.longitude,
'source': source,
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
'sourceTimestampMillis': sourceTimestamp?.millisecondsSinceEpoch,
};
}
static MessageContactLocation? fromJson(Map<String, dynamic> json) {
final latitude = json['latitude'];
final longitude = json['longitude'];
final source = json['source'];
final capturedAtMillis = json['capturedAtMillis'];
if (latitude is! num ||
longitude is! num ||
source is! String ||
capturedAtMillis is! int) {
return null;
}
return MessageContactLocation(
location: LatLng(latitude.toDouble(), longitude.toDouble()),
source: source,
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
sourceTimestamp: json['sourceTimestampMillis'] is int
? DateTime.fromMillisecondsSinceEpoch(
json['sourceTimestampMillis'] as int,
)
: null,
);
}
}

View File

@@ -0,0 +1,86 @@
const int _transmitEstimateToleranceMs = 1500;
int? sanitizeEstimatedTransmitMs({
required int? estimatedTransmitMs,
required int? senderToReceiptMs,
}) {
if (estimatedTransmitMs == null || estimatedTransmitMs <= 0) {
return null;
}
if (senderToReceiptMs == null || senderToReceiptMs <= 0) {
return estimatedTransmitMs;
}
// Sender timestamps are second-granularity, so allow a small cushion before
// treating the estimate as impossible for the observed delivery time.
if (estimatedTransmitMs > senderToReceiptMs + _transmitEstimateToleranceMs) {
return null;
}
return estimatedTransmitMs;
}
class MessageReceptionDetails {
final DateTime capturedAt;
final DateTime? packetLoggedAt;
final int? rssiDbm;
final double? snrDb;
final List<int>? pathBytes;
final int? senderToReceiptMs;
final int? estimatedTransmitMs;
final int? postTransmitDelayMs;
const MessageReceptionDetails({
required this.capturedAt,
this.packetLoggedAt,
this.rssiDbm,
this.snrDb,
this.pathBytes,
this.senderToReceiptMs,
this.estimatedTransmitMs,
this.postTransmitDelayMs,
});
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
? null
: pathBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
Map<String, dynamic> toJson() {
return {
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch,
'rssiDbm': rssiDbm,
'snrDb': snrDb,
'pathBytes': pathBytes,
'senderToReceiptMs': senderToReceiptMs,
'estimatedTransmitMs': estimatedTransmitMs,
'postTransmitDelayMs': postTransmitDelayMs,
};
}
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
final capturedAtMillis = json['capturedAtMillis'];
if (capturedAtMillis is! int) {
return null;
}
final pathBytes = json['pathBytes'];
return MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
packetLoggedAt: json['packetLoggedAtMillis'] is int
? DateTime.fromMillisecondsSinceEpoch(
json['packetLoggedAtMillis'] as int,
)
: null,
rssiDbm: json['rssiDbm'] as int?,
snrDb: (json['snrDb'] as num?)?.toDouble(),
pathBytes: pathBytes is List
? pathBytes.whereType<num>().map((b) => b.toInt()).toList()
: null,
senderToReceiptMs: json['senderToReceiptMs'] as int?,
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
);
}
}

View File

@@ -0,0 +1,79 @@
import 'path_selection.dart';
class MessageRouteMetadata {
final PathSelectionMode mode;
final bool routerFallbackAttempted;
final String? relayName;
final String? relayKey6;
final String? canonicalPath;
final int? hopCount;
const MessageRouteMetadata({
required this.mode,
required this.routerFallbackAttempted,
this.relayName,
this.relayKey6,
this.canonicalPath,
this.hopCount,
});
factory MessageRouteMetadata.fromSelection(
PathSelection selection, {
required bool routerFallbackAttempted,
}) {
return MessageRouteMetadata(
mode: selection.mode,
routerFallbackAttempted: routerFallbackAttempted,
relayName: selection.relayName,
relayKey6: selection.relayKey6,
canonicalPath: selection.canonicalPath.isEmpty
? null
: selection.canonicalPath,
hopCount: selection.hopCount > 0 ? selection.hopCount : null,
);
}
String get modeLabel {
switch (mode) {
case PathSelectionMode.directCurrent:
return 'Current direct path';
case PathSelectionMode.directHistorical:
return 'Rotated direct path';
case PathSelectionMode.flood:
return 'Flood route';
case PathSelectionMode.nearestRouter:
final suffix = relayName?.trim().isNotEmpty == true
? ' via $relayName'
: relayKey6?.trim().isNotEmpty == true
? ' via $relayKey6'
: '';
return 'Nearest router$suffix';
}
}
Map<String, dynamic> toJson() {
return {
'mode': mode.name,
'router_fallback_attempted': routerFallbackAttempted,
'relay_name': relayName,
'relay_key6': relayKey6,
'canonical_path': canonicalPath,
'hop_count': hopCount,
};
}
factory MessageRouteMetadata.fromJson(Map<String, dynamic> json) {
return MessageRouteMetadata(
mode: PathSelectionMode.values.firstWhere(
(value) => value.name == json['mode'],
orElse: () => PathSelectionMode.directCurrent,
),
routerFallbackAttempted:
json['router_fallback_attempted'] as bool? ?? false,
relayName: json['relay_name'] as String?,
relayKey6: json['relay_key6'] as String?,
canonicalPath: json['canonical_path'] as String?,
hopCount: json['hop_count'] as int?,
);
}
}

View File

@@ -0,0 +1,142 @@
class MessageTransferDownloader {
final String requesterKey6;
final String? requesterName;
final int transferCount;
final DateTime lastTransferredAt;
const MessageTransferDownloader({
required this.requesterKey6,
this.requesterName,
required this.transferCount,
required this.lastTransferredAt,
});
MessageTransferDownloader copyWith({
String? requesterKey6,
String? requesterName,
int? transferCount,
DateTime? lastTransferredAt,
}) {
return MessageTransferDownloader(
requesterKey6: requesterKey6 ?? this.requesterKey6,
requesterName: requesterName ?? this.requesterName,
transferCount: transferCount ?? this.transferCount,
lastTransferredAt: lastTransferredAt ?? this.lastTransferredAt,
);
}
Map<String, dynamic> toJson() {
return {
'requesterKey6': requesterKey6,
'requesterName': requesterName,
'transferCount': transferCount,
'lastTransferredAtMillis': lastTransferredAt.millisecondsSinceEpoch,
};
}
static MessageTransferDownloader? fromJson(Map<String, dynamic> json) {
final requesterKey6 = json['requesterKey6'];
final transferCount = json['transferCount'];
final lastTransferredAtMillis = json['lastTransferredAtMillis'];
if (requesterKey6 is! String ||
transferCount is! int ||
lastTransferredAtMillis is! int) {
return null;
}
return MessageTransferDownloader(
requesterKey6: requesterKey6,
requesterName: json['requesterName'] as String?,
transferCount: transferCount,
lastTransferredAt: DateTime.fromMillisecondsSinceEpoch(
lastTransferredAtMillis,
),
);
}
}
class MessageTransferDetails {
final int totalTransfers;
final List<MessageTransferDownloader> downloaders;
const MessageTransferDetails({
required this.totalTransfers,
required this.downloaders,
});
const MessageTransferDetails.empty()
: totalTransfers = 0,
downloaders = const [];
MessageTransferDetails registerTransfer({
required String requesterKey6,
String? requesterName,
DateTime? transferredAt,
}) {
final eventAt = transferredAt ?? DateTime.now();
final normalizedName = requesterName?.trim();
final updatedDownloaders = List<MessageTransferDownloader>.from(
downloaders,
);
final index = updatedDownloaders.indexWhere(
(entry) => entry.requesterKey6 == requesterKey6,
);
if (index == -1) {
updatedDownloaders.add(
MessageTransferDownloader(
requesterKey6: requesterKey6,
requesterName: normalizedName?.isEmpty ?? true
? null
: normalizedName,
transferCount: 1,
lastTransferredAt: eventAt,
),
);
} else {
final existing = updatedDownloaders[index];
updatedDownloaders[index] = existing.copyWith(
requesterName: normalizedName?.isEmpty ?? true
? existing.requesterName
: normalizedName,
transferCount: existing.transferCount + 1,
lastTransferredAt: eventAt,
);
}
updatedDownloaders.sort(
(a, b) => b.lastTransferredAt.compareTo(a.lastTransferredAt),
);
return MessageTransferDetails(
totalTransfers: totalTransfers + 1,
downloaders: updatedDownloaders,
);
}
Map<String, dynamic> toJson() {
return {
'totalTransfers': totalTransfers,
'downloaders': downloaders.map((entry) => entry.toJson()).toList(),
};
}
static MessageTransferDetails? fromJson(Map<String, dynamic> json) {
final totalTransfers = json['totalTransfers'];
if (totalTransfers is! int) {
return null;
}
final rawDownloaders = json['downloaders'] as List<dynamic>? ?? const [];
final downloaders = rawDownloaders
.whereType<Map<String, dynamic>>()
.map(MessageTransferDownloader.fromJson)
.whereType<MessageTransferDownloader>()
.toList();
return MessageTransferDetails(
totalTransfers: totalTransfers,
downloaders: downloaders,
);
}
}

View File

@@ -0,0 +1,182 @@
class PathRecord {
final List<int> pathBytes;
final int hopCount;
final int hashSize;
final int successCount;
final int failureCount;
final int lastRoundTripTimeMs;
final DateTime lastUsedAt;
const PathRecord({
required this.pathBytes,
required this.hopCount,
required this.hashSize,
required this.successCount,
required this.failureCount,
required this.lastRoundTripTimeMs,
required this.lastUsedAt,
});
String get signature =>
pathBytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
double get successRate =>
(successCount + 1) / (successCount + failureCount + 2);
PathRecord copyWith({
List<int>? pathBytes,
int? hopCount,
int? hashSize,
int? successCount,
int? failureCount,
int? lastRoundTripTimeMs,
DateTime? lastUsedAt,
}) {
return PathRecord(
pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize,
successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
);
}
Map<String, dynamic> toJson() {
return {
'path_bytes': pathBytes,
'hop_count': hopCount,
'hash_size': hashSize,
'success_count': successCount,
'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt.toIso8601String(),
};
}
factory PathRecord.fromJson(Map<String, dynamic> json) {
return PathRecord(
pathBytes: (json['path_bytes'] as List<dynamic>? ?? const <dynamic>[])
.map((value) => value as int)
.toList(),
hopCount: json['hop_count'] as int? ?? 0,
hashSize: json['hash_size'] as int? ?? 1,
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),
);
}
}
class FloodPathStats {
final int successCount;
final int failureCount;
final int lastRoundTripTimeMs;
final DateTime? lastUsedAt;
const FloodPathStats({
required this.successCount,
required this.failureCount,
required this.lastRoundTripTimeMs,
required this.lastUsedAt,
});
const FloodPathStats.empty()
: successCount = 0,
failureCount = 0,
lastRoundTripTimeMs = 0,
lastUsedAt = null;
FloodPathStats copyWith({
int? successCount,
int? failureCount,
int? lastRoundTripTimeMs,
DateTime? lastUsedAt,
}) {
return FloodPathStats(
successCount: successCount ?? this.successCount,
failureCount: failureCount ?? this.failureCount,
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
);
}
Map<String, dynamic> toJson() {
return {
'success_count': successCount,
'failure_count': failureCount,
'last_round_trip_time_ms': lastRoundTripTimeMs,
'last_used_at': lastUsedAt?.toIso8601String(),
};
}
factory FloodPathStats.fromJson(Map<String, dynamic> json) {
return FloodPathStats(
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? ?? ''),
);
}
}
class ContactPathHistory {
final String contactPublicKeyHex;
final List<PathRecord> directPaths;
final FloodPathStats floodStats;
final int rotationIndex;
const ContactPathHistory({
required this.contactPublicKeyHex,
required this.directPaths,
required this.floodStats,
required this.rotationIndex,
});
const ContactPathHistory.empty(this.contactPublicKeyHex)
: directPaths = const <PathRecord>[],
floodStats = const FloodPathStats.empty(),
rotationIndex = 0;
ContactPathHistory copyWith({
List<PathRecord>? directPaths,
FloodPathStats? floodStats,
int? rotationIndex,
}) {
return ContactPathHistory(
contactPublicKeyHex: contactPublicKeyHex,
directPaths: directPaths ?? this.directPaths,
floodStats: floodStats ?? this.floodStats,
rotationIndex: rotationIndex ?? this.rotationIndex,
);
}
Map<String, dynamic> toJson() {
return {
'direct_paths': directPaths.map((record) => record.toJson()).toList(),
'flood_stats': floodStats.toJson(),
'rotation_index': rotationIndex,
};
}
factory ContactPathHistory.fromJson(
String contactPublicKeyHex,
Map<String, dynamic> json,
) {
return ContactPathHistory(
contactPublicKeyHex: contactPublicKeyHex,
directPaths: (json['direct_paths'] as List<dynamic>? ?? const <dynamic>[])
.whereType<Map<String, dynamic>>()
.map(PathRecord.fromJson)
.toList(),
floodStats: json['flood_stats'] is Map<String, dynamic>
? FloodPathStats.fromJson(json['flood_stats'] as Map<String, dynamic>)
: const FloodPathStats.empty(),
rotationIndex: json['rotation_index'] as int? ?? 0,
);
}
}

View File

@@ -0,0 +1,65 @@
import 'dart:typed_data';
enum PathSelectionMode { directCurrent, directHistorical, flood, nearestRouter }
class PathSelection {
final PathSelectionMode mode;
final Uint8List pathBytes;
final int hopCount;
final int hashSize;
final String? relayName;
final String? relayKey6;
const PathSelection({
required this.mode,
required this.pathBytes,
required this.hopCount,
required this.hashSize,
this.relayName,
this.relayKey6,
});
PathSelection.flood()
: mode = PathSelectionMode.flood,
pathBytes = Uint8List(0),
hopCount = -1,
hashSize = 1,
relayName = null,
relayKey6 = null;
bool get usesFlood => mode == PathSelectionMode.flood;
bool get hasDirectPath => !usesFlood && pathBytes.isNotEmpty && hopCount > 0;
String get canonicalPath {
if (!hasDirectPath) return '';
final hops = <String>[];
for (var index = 0; index < pathBytes.length; index += hashSize) {
hops.add(
pathBytes
.sublist(index, index + hashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase(),
);
}
return hops.join(',');
}
PathSelection copyWith({
PathSelectionMode? mode,
Uint8List? pathBytes,
int? hopCount,
int? hashSize,
String? relayName,
String? relayKey6,
}) {
return PathSelection(
mode: mode ?? this.mode,
pathBytes: pathBytes ?? this.pathBytes,
hopCount: hopCount ?? this.hopCount,
hashSize: hashSize ?? this.hashSize,
relayName: relayName ?? this.relayName,
relayKey6: relayKey6 ?? this.relayKey6,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,11 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:crypto/crypto.dart';
import '../models/contact.dart';
import '../models/device_info.dart';
import '../models/room_login_state.dart';
import '../models/sse_server_config.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
import '../services/sse_server_service.dart';
import '../utils/sar_message_parser.dart';
import 'helpers/room_login_manager.dart';
@@ -89,6 +90,8 @@ class ConnectionProvider with ChangeNotifier {
bool _isScanning = false;
bool get isScanning => _isScanning;
bool _isSpectrumScanActive = false;
bool get isSpectrumScanActive => _isSpectrumScanActive;
String? _error;
String? get error => _error;
@@ -144,11 +147,15 @@ class ConnectionProvider with ChangeNotifier {
final MessageDeliveryTracker _messageDeliveryTracker =
MessageDeliveryTracker();
final PingTracker _pingTracker = PingTracker();
final Map<String, Future<PingResult>> _pendingSmartPings = {};
// Expose room login states
Map<String, RoomLoginState> get roomLoginStates =>
_roomLoginManager.roomLoginStates;
bool isPingInProgress(Uint8List publicKey) =>
_pendingSmartPings.containsKey(_publicKeyToHex(publicKey));
// Callbacks for other providers
Function(Contact)? onContactReceived;
Function(List<Contact>)? onContactsComplete;
@@ -168,6 +175,12 @@ class ConnectionProvider with ChangeNotifier {
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
onMessageSent;
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
Future<Contact?> Function({
required String messageId,
required Contact contact,
required int retryAttempt,
})?
prepareDirectMessageSendCallback;
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
@@ -247,7 +260,10 @@ class ConnectionProvider with ChangeNotifier {
await Future.delayed(const Duration(milliseconds: 300));
if (pendingOp.messageId != null) {
_messageDeliveryTracker.trackPendingMessage(pendingOp.messageId!);
_messageDeliveryTracker.trackPendingDirectMessage(
pendingOp.messageId!,
pendingOp.contactPublicKey,
);
}
await _activeService.sendTextMessage(
@@ -312,6 +328,10 @@ class ConnectionProvider with ChangeNotifier {
};
service.onMessageWaiting = () {
if (_isSpectrumScanActive) {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return;
}
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
if (_isSyncingMessages) {
_syncRequestedWhileBusy = true;
@@ -346,7 +366,11 @@ class ConnectionProvider with ChangeNotifier {
service.onMessageSent =
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
final messageId = _messageDeliveryTracker.popPendingMessageId();
final messageId = contactPublicKey != null
? _messageDeliveryTracker.popPendingDirectMessageId(
contactPublicKey,
)
: _messageDeliveryTracker.popPendingMessageId();
if (messageId != null) {
_messageDeliveryTracker.mapAckTagToMessageId(
expectedAckTag,
@@ -379,6 +403,9 @@ class ConnectionProvider with ChangeNotifier {
manufacturerModel: deviceInfo['manufacturerModel'] as String?,
semanticVersion: deviceInfo['semanticVersion'] as String?,
clientRepeat: deviceInfo['clientRepeat'] as bool?,
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
);
notifyListeners();
if (_sseServer.isRunning) {
@@ -459,6 +486,14 @@ class ConnectionProvider with ChangeNotifier {
/// Start scanning for MeshCore devices
Future<void> startScan() async {
debugPrint('🔍 [Provider] startScan() called');
if (_deviceInfo.connectionState == ConnectionState.connecting ||
_deviceInfo.connectionState == ConnectionState.connected) {
debugPrint(
'⏭️ [Provider] Ignoring scan request while connection is active: ${_deviceInfo.connectionState}',
);
return;
}
_isScanning = true;
_scannedDevices.clear();
_error = null;
@@ -532,6 +567,11 @@ class ConnectionProvider with ChangeNotifier {
'🔵 [Provider] connect() called for device: ${device.platformName}',
);
if (_isScanning) {
debugPrint('🔵 [Provider] Stopping active scan before connect()');
await stopScan();
}
_deviceInfo = _deviceInfo.copyWith(
deviceId: device.remoteId.toString(),
deviceName: device.platformName.isNotEmpty
@@ -1052,7 +1092,7 @@ class ConnectionProvider with ChangeNotifier {
///
/// [messageId] - optional message ID to track delivery status
/// [contact] - optional contact object for path status logging
/// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries)
/// [retryAttempt] - retry attempt number (0 = first send, >0 = retries)
Future<bool> sendTextMessage({
required Uint8List contactPublicKey,
required String text,
@@ -1066,6 +1106,17 @@ class ConnectionProvider with ChangeNotifier {
return false;
}
var effectiveContact = contact;
if (messageId != null &&
effectiveContact != null &&
prepareDirectMessageSendCallback != null) {
effectiveContact = await prepareDirectMessageSendCallback!(
messageId: messageId,
contact: effectiveContact,
retryAttempt: retryAttempt,
);
}
// CRITICAL: Check firmware ACK limit (8 max in circular buffer)
// Rate limit at 7 to stay under the limit
if (_messageDeliveryTracker.shouldRateLimit) {
@@ -1090,31 +1141,33 @@ class ConnectionProvider with ChangeNotifier {
try {
// Log path status and retry info
if (contact != null) {
if (effectiveContact != null) {
if (retryAttempt > 0) {
debugPrint(
'🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)',
'🔄 [ConnectionProvider] Sending message to ${effectiveContact.advName} (retry $retryAttempt)',
);
} else {
debugPrint(
'📤 [ConnectionProvider] Sending message to ${contact.advName}',
'📤 [ConnectionProvider] Sending message to ${effectiveContact.advName}',
);
}
debugPrint(' Type: ${contact.type.displayName}');
debugPrint(' Path status: ${contact.pathDescription}');
if (contact.hasPath) {
debugPrint(' ✅ Using learned path (${contact.outPathLen} bytes)');
debugPrint(' Type: ${effectiveContact.type.displayName}');
debugPrint(' Path status: ${effectiveContact.routeSummary}');
if (effectiveContact.routeHasPath) {
debugPrint(
' ✅ Using learned path (${effectiveContact.routeHopCount} hop(s), ${effectiveContact.routeHashSize}-byte hashes)',
);
} else {
debugPrint(' ⚠️ No path available - will use flood mode');
}
} else if (retryAttempt > 0) {
debugPrint(
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)',
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt)',
);
}
// Track pending operation for auto-recovery (if contact not found in radio)
if (contact != null) {
if (effectiveContact != null) {
final operationId = contactPublicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
@@ -1123,7 +1176,7 @@ class ConnectionProvider with ChangeNotifier {
contactPublicKey: contactPublicKey,
text: text,
messageId: messageId,
contact: contact,
contact: effectiveContact,
retryAttempt: retryAttempt,
);
debugPrint(
@@ -1139,7 +1192,10 @@ class ConnectionProvider with ChangeNotifier {
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
// We still track here for the SENT response callback to work.
if (messageId != null) {
_messageDeliveryTracker.trackPendingMessage(messageId);
_messageDeliveryTracker.trackPendingDirectMessage(
messageId,
contactPublicKey,
);
debugPrint(' 📝 Tracked pending message: $messageId');
}
@@ -1150,9 +1206,22 @@ class ConnectionProvider with ChangeNotifier {
attempt: retryAttempt,
);
if (messageId != null) {
Future.delayed(const Duration(milliseconds: 350), () {
if (_messageDeliveryTracker.hasAckForMessage(messageId)) {
return;
}
debugPrint(
' [ConnectionProvider] Missing RESP_CODE_SENT for $messageId; promoting to sent via fallback',
);
onMessageSent?.call(messageId, 0, 0);
});
}
// Clear pending operation after successful send (no error)
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
if (contact != null) {
if (effectiveContact != null) {
final operationId = contactPublicKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
@@ -1251,6 +1320,19 @@ class ConnectionProvider with ChangeNotifier {
);
}
/// Send a raw private zero-hop payload.
///
/// This wraps the raw custom transport using an empty path to match the
/// firmware's private multicast behavior.
Future<void> sendRawPrivateMulticast(Uint8List payload) async {
if (!_activeService.isConnected) return;
await _activeService.sendRawVoicePacket(
contactPathLen: 0,
contactPath: Uint8List(0),
payload: payload,
);
}
/// Request telemetry from contact
///
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
@@ -1297,6 +1379,34 @@ class ConnectionProvider with ChangeNotifier {
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async {
final pingKey = _publicKeyToHex(contactPublicKey);
final pendingPing = _pendingSmartPings[pingKey];
if (pendingPing != null) {
debugPrint(' [Provider] Joining in-flight ping for $pingKey');
return pendingPing;
}
final future = _runSmartPing(
contactPublicKey: contactPublicKey,
hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
_pendingSmartPings[pingKey] = future;
notifyListeners();
try {
return await future;
} finally {
_pendingSmartPings.remove(pingKey);
notifyListeners();
}
}
Future<PingResult> _runSmartPing({
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -1315,7 +1425,10 @@ class ConnectionProvider with ChangeNotifier {
);
// Send the ping
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
await _activeService.requestTelemetry(
contactPublicKey,
zeroHop: firstAttemptDirect,
);
// Wait for response or timeout
final bool gotResponse = await pingFuture;
@@ -1342,8 +1455,8 @@ class ConnectionProvider with ChangeNotifier {
wasDirectAttempt: false,
);
// Retry with flooding (zeroHop=true acts as broadcast to neighbors)
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
// Retry with flooding.
await _activeService.requestTelemetry(contactPublicKey, zeroHop: false);
// Wait for response or timeout
final bool gotRetryResponse = await retryFuture;
@@ -1365,6 +1478,10 @@ class ConnectionProvider with ChangeNotifier {
}
}
String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Send binary request to contact (modern replacement for requestTelemetry)
///
/// Supports multiple request types:
@@ -1555,6 +1672,43 @@ class ConnectionProvider with ChangeNotifier {
}
}
Future<SpectrumScanResult?> scanSpectrum({
required int startFrequencyKhz,
required int stopFrequencyKhz,
required int bandwidthKhz,
required int stepKhz,
required int dwellMs,
required int thresholdDb,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return null;
}
try {
_isSpectrumScanActive = true;
_activeService.setSpectrumScanActive(true);
notifyListeners();
return await _activeService.scanSpectrum(
startFrequencyKhz: startFrequencyKhz,
stopFrequencyKhz: stopFrequencyKhz,
bandwidthKhz: bandwidthKhz,
stepKhz: stepKhz,
dwellMs: dwellMs,
thresholdDb: thresholdDb,
);
} catch (e) {
_error = 'Failed to scan spectrum: $e';
notifyListeners();
return null;
} finally {
_isSpectrumScanActive = false;
_activeService.setSpectrumScanActive(false);
notifyListeners();
}
}
/// Set transmit power
Future<void> setTxPower(int powerDbm) async {
if (!_activeService.isConnected) {
@@ -1599,6 +1753,7 @@ class ConnectionProvider with ChangeNotifier {
/// Request fresh device info (triggers SelfInfo response)
Future<void> refreshDeviceInfo() async {
if (_isSpectrumScanActive) return;
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
@@ -1642,6 +1797,7 @@ class ConnectionProvider with ChangeNotifier {
/// Sync messages from device queue
/// Call this repeatedly until no more messages are available
Future<bool> syncNextMessage() async {
if (_isSpectrumScanActive) return false;
// Prevent re-entrancy and too-fast triggers
if (_isSyncingMessages) {
// Another sync (single or loop) is in progress
@@ -1680,6 +1836,10 @@ class ConnectionProvider with ChangeNotifier {
/// Sync all waiting messages from device
Future<int> syncAllMessages() async {
if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0;
}
if (_isSyncingMessages) {
// Already syncing; avoid overlapping loops
_syncRequestedWhileBusy = true;
@@ -1898,6 +2058,7 @@ class ConnectionProvider with ChangeNotifier {
}
try {
_error = null;
await _activeService.resetPath(contactPublicKey);
} catch (e) {
_error = 'Failed to reset path: $e';
@@ -1905,6 +2066,31 @@ class ConnectionProvider with ChangeNotifier {
}
}
Future<void> setContactRoute(
Contact contact, {
required int signedEncodedPathLen,
required Uint8List paddedPathBytes,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
_error = null;
final updatedContact = contact.copyWith(
outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes),
);
await _activeService.addOrUpdateContact(updatedContact);
} catch (e) {
_error = 'Failed to set route: $e';
notifyListeners();
rethrow;
}
}
/// Remove a contact from the companion radio
///
/// Deletes the contact from the device's internal contact table.

View File

@@ -1,8 +1,10 @@
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/contact.dart';
import '../models/message_contact_location.dart';
import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart';
import '../utils/fast_gps_packet.dart';
import '../utils/key_comparison.dart';
class PendingAdvert {
@@ -214,6 +216,52 @@ class ContactsProvider with ChangeNotifier {
List<Contact> get chatContactsWithLocation =>
chatContacts.where((c) => c.displayLocation != null).toList();
MessageContactLocation? buildMessageContactLocationSnapshot(
Contact contact, {
DateTime? capturedAt,
}) {
final snapshotTime = capturedAt ?? DateTime.now();
final telemetryGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation);
final telemetryTimestamp = contact.telemetry?.timestamp;
AdvertLocation? advertLocation;
for (final point in contact.advertHistory) {
if (!point.timestamp.isAfter(snapshotTime)) {
advertLocation = point;
break;
}
}
advertLocation ??= contact.advertHistory.isNotEmpty
? contact.advertHistory.first
: null;
if (telemetryGps != null) {
final shouldUseTelemetry =
telemetryTimestamp == null ||
advertLocation == null ||
!telemetryTimestamp.isBefore(advertLocation.timestamp);
if (shouldUseTelemetry) {
return MessageContactLocation(
location: telemetryGps,
source: 'telemetry',
capturedAt: snapshotTime,
sourceTimestamp: telemetryTimestamp,
);
}
}
if (advertLocation != null) {
return MessageContactLocation(
location: advertLocation.location,
source: 'advert',
capturedAt: snapshotTime,
sourceTimestamp: advertLocation.timestamp,
);
}
return null;
}
/// Sort contacts by last seen (most recent first)
int _sortByLastSeen(Contact a, Contact b) {
return b.lastSeenTime.compareTo(a.lastSeenTime);
@@ -235,45 +283,16 @@ class ContactsProvider with ChangeNotifier {
}
// Check if this is a new contact
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
final existingContact = _contacts[contact.publicKeyHex];
final isNewContact = existingContact == null;
debugPrint(
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
);
Contact updatedContact;
if (isNewContact) {
// New contact - add initial location to history if available
updatedContact = contact.copyWith(isNew: true);
if (contact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
contact.lastAdvert * 1000,
);
updatedContact = updatedContact.addAdvertLocation(
contact.advertLocation!,
timestamp,
);
}
} else {
// Existing contact - preserve history and isNew status
final existingContact = _contacts[contact.publicKeyHex]!;
// Start with existing contact
updatedContact = contact.copyWith(
isNew: existingContact.isNew,
advertHistory: existingContact.advertHistory,
);
// Add new location to history if location has changed
if (contact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
contact.lastAdvert * 1000,
);
updatedContact = updatedContact.addAdvertLocation(
contact.advertLocation!,
timestamp,
);
}
}
final updatedContact = _mergeIncomingContact(
incomingContact: contact,
existingContact: existingContact,
);
_contacts[contact.publicKeyHex] = updatedContact;
_pendingAdverts.remove(contact.publicKeyHex);
@@ -299,7 +318,11 @@ class ContactsProvider with ChangeNotifier {
excluded++;
continue;
}
_contacts[contact.publicKeyHex] = contact;
final existingContact = _contacts[contact.publicKeyHex];
_contacts[contact.publicKeyHex] = _mergeIncomingContact(
incomingContact: contact,
existingContact: existingContact,
);
_pendingAdverts.remove(contact.publicKeyHex);
}
if (excluded > 0) {
@@ -311,6 +334,60 @@ class ContactsProvider with ChangeNotifier {
notifyListeners();
}
Contact _mergeIncomingContact({
required Contact incomingContact,
Contact? existingContact,
}) {
if (existingContact == null) {
var newContact = incomingContact.copyWith(isNew: true);
if (incomingContact.advertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
incomingContact.lastAdvert * 1000,
);
newContact = newContact.addAdvertLocation(
incomingContact.advertLocation!,
timestamp,
);
}
return newContact;
}
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: existingContact.telemetry,
incomingTelemetry: incomingContact.telemetry,
);
final incomingAdvertLocation = incomingContact.advertLocation;
final existingAdvertLocation = existingContact.advertLocation;
var updatedContact = incomingContact.copyWith(
isNew: existingContact.isNew,
advertHistory: existingContact.advertHistory,
telemetry: mergedTelemetry,
advLat: incomingAdvertLocation != null
? incomingContact.advLat
: existingAdvertLocation != null
? existingContact.advLat
: incomingContact.advLat,
advLon: incomingAdvertLocation != null
? incomingContact.advLon
: existingAdvertLocation != null
? existingContact.advLon
: incomingContact.advLon,
);
if (incomingAdvertLocation != null) {
final timestamp = DateTime.fromMillisecondsSinceEpoch(
incomingContact.lastAdvert * 1000,
);
updatedContact = updatedContact.addAdvertLocation(
incomingAdvertLocation,
timestamp,
);
}
return updatedContact;
}
/// Update contact telemetry
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
@@ -350,23 +427,22 @@ class ContactsProvider with ChangeNotifier {
);
}
// Keep last valid GPS for router/chat/room contacts when current
// telemetry does not provide a valid GPS fix.
if (_shouldRetainLastValidGps(contact, telemetry.gpsLocation)) {
debugPrint(
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
);
final previousGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation);
telemetry = ContactTelemetry(
gpsLocation: previousGps,
batteryPercentage: telemetry.batteryPercentage,
batteryMilliVolts: telemetry.batteryMilliVolts,
temperature: telemetry.temperature,
timestamp: telemetry.timestamp,
humidity: telemetry.humidity,
pressure: telemetry.pressure,
extraSensorData: telemetry.extraSensorData,
);
final previousTelemetry = contact.telemetry;
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: previousTelemetry,
incomingTelemetry: telemetry,
);
if (mergedTelemetry != null) {
if (_shouldRetainLastValidGps(
previousTelemetry,
telemetry.gpsLocation,
)) {
debugPrint(
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
);
}
telemetry = mergedTelemetry;
}
// Update contact with new telemetry AND last seen time
@@ -376,9 +452,16 @@ class ContactsProvider with ChangeNotifier {
debugPrint(' Old lastAdvert: ${contact.lastAdvert}');
debugPrint(' New lastAdvert: $currentTimestamp');
final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation);
final updatedContact = contact.copyWith(
telemetry: telemetry,
lastAdvert: currentTimestamp, // Update last seen time
advLat: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.latitude)
: contact.advLat,
advLon: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.longitude)
: contact.advLon,
);
_contacts[contact.publicKeyHex] = updatedContact;
debugPrint(' ✅ Updated contact in map (with new lastAdvert)');
@@ -394,6 +477,42 @@ class ContactsProvider with ChangeNotifier {
}
}
void updateFastGps(Uint8List publicKeyPrefix, FastGpsPacket packet) {
final contact = _findContactByPrefix(publicKeyPrefix);
if (contact == null) {
debugPrint(
'⚠️ [ContactsProvider] Fast GPS sender not found: ${packet.senderKey6}',
);
return;
}
final updatedTelemetry = _mergeTelemetryForContact(
existingTelemetry: contact.telemetry,
incomingTelemetry: ContactTelemetry(
gpsLocation: LatLng(packet.latitude, packet.longitude),
batteryPercentage: null,
batteryMilliVolts: null,
temperature: null,
timestamp: DateTime.fromMillisecondsSinceEpoch(
packet.timestampSeconds * 1000,
),
humidity: null,
pressure: null,
extraSensorData: null,
),
);
final updatedContact = contact.copyWith(
telemetry: updatedTelemetry,
lastAdvert: packet.timestampSeconds,
advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
);
_contacts[contact.publicKeyHex] = updatedContact;
_persistContacts();
notifyListeners();
}
bool _isInvalidTelemetryGps(LatLng? location) {
if (location == null) return false;
final lat = location.latitude;
@@ -413,15 +532,12 @@ class ContactsProvider with ChangeNotifier {
return location;
}
bool _shouldRetainLastValidGps(Contact contact, LatLng? incomingGps) {
final isSupportedType =
contact.isChat || contact.isRepeater || contact.isRoom;
if (!isSupportedType) {
return false;
}
bool _shouldRetainLastValidGps(
ContactTelemetry? existingTelemetry,
LatLng? incomingGps,
) {
final hasPreviousValidGps =
_getValidGpsOrNull(contact.telemetry?.gpsLocation) != null;
_getValidGpsOrNull(existingTelemetry?.gpsLocation) != null;
if (!hasPreviousValidGps) {
return false;
}
@@ -429,6 +545,46 @@ class ContactsProvider with ChangeNotifier {
return incomingGps == null;
}
ContactTelemetry? _mergeTelemetryForContact({
ContactTelemetry? existingTelemetry,
ContactTelemetry? incomingTelemetry,
}) {
if (incomingTelemetry == null) {
return existingTelemetry;
}
// Telemetry packets and contact refreshes are often sparse. Preserve the
// last known reading for any field that is omitted in the incoming update.
final incomingGps = _getValidGpsOrNull(incomingTelemetry.gpsLocation);
final previousGps = _getValidGpsOrNull(existingTelemetry?.gpsLocation);
final mergedExtraSensorData = <String, dynamic>{
...?existingTelemetry?.extraSensorData,
...?incomingTelemetry.extraSensorData,
};
return ContactTelemetry(
gpsLocation: incomingGps ?? previousGps,
batteryPercentage:
incomingTelemetry.batteryPercentage ??
existingTelemetry?.batteryPercentage,
batteryMilliVolts:
incomingTelemetry.batteryMilliVolts ??
existingTelemetry?.batteryMilliVolts,
temperature:
incomingTelemetry.temperature ?? existingTelemetry?.temperature,
timestamp: incomingTelemetry.timestamp,
humidity: incomingTelemetry.humidity ?? existingTelemetry?.humidity,
pressure: incomingTelemetry.pressure ?? existingTelemetry?.pressure,
extraSensorData: mergedExtraSensorData.isEmpty
? null
: mergedExtraSensorData,
);
}
int _coordinateToAdvertMicrodegrees(double coordinate) {
return (coordinate * 1e6).round();
}
/// Find contact by public key prefix (6 bytes)
Contact? _findContactByPrefix(Uint8List prefix) {
if (prefix.length < 6) return null;
@@ -470,6 +626,54 @@ class ContactsProvider with ChangeNotifier {
return _contacts[keyHex];
}
/// Clear a contact's learned path locally so the UI and next send both
/// prefer flood routing until the radio reports a fresh route.
void markPathUnhealthy(Uint8List publicKey) {
final contact = findContactByKey(publicKey);
if (contact == null || !contact.routeHasPath) {
return;
}
_contacts[contact.publicKeyHex] = contact.copyWith(
outPathLen: -1,
outPath: Uint8List(0),
);
_persistContacts();
notifyListeners();
}
void setContactRouteLocal(
Uint8List publicKey, {
required int signedEncodedPathLen,
required Uint8List paddedPathBytes,
}) {
final contact = findContactByKey(publicKey);
if (contact == null) {
return;
}
_contacts[contact.publicKeyHex] = contact.copyWith(
outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes),
);
_persistContacts();
notifyListeners();
}
void resetContactRouteLocal(Uint8List publicKey) {
final contact = findContactByKey(publicKey);
if (contact == null) {
return;
}
_contacts[contact.publicKeyHex] = contact.copyWith(
outPathLen: -1,
outPath: Uint8List(0),
);
_persistContacts();
notifyListeners();
}
/// Add or refresh a pending advert entry from PUSH_CODE_ADVERT (0x80).
/// Excludes self key and existing contacts.
void addPendingAdvert(Uint8List publicKey, {Uint8List? devicePublicKey}) {

View File

@@ -1,3 +1,5 @@
import 'dart:typed_data';
/// Message delivery tracking helper
///
/// Manages message delivery tracking for sent messages, including:
@@ -16,6 +18,9 @@ class MessageDeliveryTracker {
/// Messages tracked here before sending, popped when RESP_CODE_SENT arrives
final List<String> _pendingMessageIds = [];
/// Contact-scoped FIFOs for matching direct-message SENT responses.
final Map<String, List<String>> _pendingMessageIdsByContact = {};
/// Map of ACK tag to message ID for delivery confirmation
final Map<int, String> _ackTagToMessageId = {};
@@ -33,6 +38,15 @@ class MessageDeliveryTracker {
_pendingMessageIds.add(messageId);
}
/// Track a pending direct message ID for a specific contact.
void trackPendingDirectMessage(String messageId, Uint8List contactPublicKey) {
trackPendingMessage(messageId);
final contactKey = _contactKey(contactPublicKey);
_pendingMessageIdsByContact
.putIfAbsent(contactKey, () => [])
.add(messageId);
}
/// Pop the next pending message ID from FIFO queue
///
/// Called when RESP_CODE_SENT arrives. Returns null if queue empty.
@@ -43,6 +57,24 @@ class MessageDeliveryTracker {
return _pendingMessageIds.removeAt(0);
}
/// Pop the next pending direct message ID for a specific contact.
///
/// Falls back to the legacy global FIFO if the contact queue is empty.
String? popPendingDirectMessageId(Uint8List contactPublicKey) {
final contactKey = _contactKey(contactPublicKey);
final queue = _pendingMessageIdsByContact[contactKey];
if (queue == null || queue.isEmpty) {
return popPendingMessageId();
}
final messageId = queue.removeAt(0);
if (queue.isEmpty) {
_pendingMessageIdsByContact.remove(contactKey);
}
_pendingMessageIds.remove(messageId);
return messageId;
}
/// Map ACK tag to message ID after RESP_CODE_SENT received
///
/// Creates bidirectional mapping for efficient cleanup and tracking.
@@ -66,6 +98,11 @@ class MessageDeliveryTracker {
return _ackTagToMessageId[ackCode];
}
/// Returns true once a message has been matched to a concrete ACK tag.
bool hasAckForMessage(String messageId) {
return _messageIdToAckTag.containsKey(messageId);
}
/// Remove ACK tag mapping after delivery confirmed or timeout
///
/// Cleans up both forward and reverse mappings.
@@ -86,6 +123,17 @@ class MessageDeliveryTracker {
_ackTagToMessageId.remove(ackTag);
_ackTagTimestamps.remove(ackTag);
}
_pendingMessageIds.remove(messageId);
final emptyKeys = <String>[];
for (final entry in _pendingMessageIdsByContact.entries) {
entry.value.remove(messageId);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingMessageIdsByContact.remove(key);
}
}
/// Clean up stale ACK mappings
@@ -114,6 +162,7 @@ class MessageDeliveryTracker {
/// Clear all tracking state
void clearTracking() {
_pendingMessageIds.clear();
_pendingMessageIdsByContact.clear();
_ackTagToMessageId.clear();
_messageIdToAckTag.clear();
_ackTagTimestamps.clear();
@@ -133,9 +182,7 @@ class MessageDeliveryTracker {
/// Get oldest pending ACK timestamp (for debugging)
DateTime? get oldestPendingTimestamp {
if (_ackTagTimestamps.isEmpty) return null;
return _ackTagTimestamps.values.reduce(
(a, b) => a.isBefore(b) ? a : b,
);
return _ackTagTimestamps.values.reduce((a, b) => a.isBefore(b) ? a : b);
}
/// Get diagnostic info for debugging
@@ -145,6 +192,15 @@ class MessageDeliveryTracker {
'shouldRateLimit': shouldRateLimit,
'oldestPending': oldestPendingTimestamp?.toIso8601String(),
'ackTags': _ackTagToMessageId.keys.toList(),
'pendingByContact': _pendingMessageIdsByContact.map(
(key, value) => MapEntry(key, value.length),
),
};
}
String _contactKey(Uint8List contactPublicKey) {
return contactPublicKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
}

View File

@@ -1,74 +1,69 @@
import 'dart:convert';
import '../../models/message.dart';
import '../../models/contact.dart';
/// Manages message retry state and logic
///
/// This helper class centralizes retry logic for direct messages, implementing
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
/// with learned routing paths.
/// This helper class centralizes retry logic for direct messages.
///
/// IMPORTANT: Based on MeshCore firmware analysis:
/// - Firmware calculates timeout based on path length and airtime
/// - Direct mode: ~(path_len * airtime * 2) + margin
/// - Flood mode: ~10-30 seconds for multi-hop
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
/// - Firmware does NOT automatically retry - app must implement
class MessageRetryManager {
// Track retry state for each message ID
final Map<String, int> _retryAttempts = {};
final Map<String, DateTime> _lastRetryTimes = {};
final Map<String, int> _pathFailureStreaks = {};
// Progressive timeout values in milliseconds
// These are app-level timeouts, separate from firmware's suggested timeout
// Firmware timeout is for ACK arrival, these are for retry attempts
static const List<int> _timeouts = [4000, 8000, 12000];
static const int maxRetryAttempts = 4;
/// Get timeout for a specific retry attempt (0-2)
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
int getTimeoutForAttempt(int attempt) {
if (attempt < 0 || attempt >= _timeouts.length) {
return _timeouts.last; // Default to last timeout if out of range
// Retry backoff values in milliseconds.
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
static const int _defaultLoRaSf = 10;
static const int _defaultLoRaCr = 5;
static const int _defaultLoRaBwHz = 250000;
static const int _defaultLoRaPreambleSymbols = 8;
static const int _defaultLoRaCrcEnabled = 1;
static const int _defaultLoRaExplicitHeader = 1;
/// Get backoff delay for the next retry attempt.
int getDelayForAttempt(int attempt) {
if (attempt < 0 || attempt >= _retryDelays.length) {
return _retryDelays.last;
}
return _timeouts[attempt];
return _retryDelays[attempt];
}
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
/// provide one or returns an invalid value.
int calculateAckTimeoutMs({
required String text,
required Contact? contact,
int? suggestedTimeoutMs,
}) {
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
return suggestedTimeoutMs;
}
final payloadBytes = utf8.encode(text).length;
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
final hopCount = contact?.routeHasPath == true
? contact!.routeHopCount
: -1;
if (hopCount < 0) {
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
}
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
}
/// Check if a message is eligible for retry
///
/// Returns true if:
/// - The message has retryAttempt < 3
/// - The contact has a learned path (contact.hasPath == true)
/// - The message hasn't used flood fallback yet
///
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
bool canRetry(Message message, Contact contact) {
// Never retry if already tried flood mode
if (message.usedFloodFallback) {
return false;
}
// Never retry beyond 3 attempts
if (message.retryAttempt >= 3) {
return false;
}
// Only retry if contact has a learned path
// If no path, the device uses flood mode automatically - retrying won't help
return contact.hasPath;
}
/// Check if should fall back to flood mode
///
/// Returns true if:
/// - Message has exhausted all 3 retry attempts with direct mode
/// - Contact HAS a learned path (so direct mode was used)
/// - Hasn't already used flood fallback
///
/// IMPORTANT: Only contacts WITH paths need flood fallback.
/// Contacts without paths already use flood mode automatically.
bool shouldUseFloodFallback(Message message, Contact contact) {
return message.retryAttempt >= 3 &&
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths
!message.usedFloodFallback;
return message.retryAttempt < maxRetryAttempts;
}
/// Track a retry attempt for a message
@@ -87,6 +82,7 @@ class MessageRetryManager {
void clearAll() {
_retryAttempts.clear();
_lastRetryTimes.clear();
_pathFailureStreaks.clear();
}
/// Get current retry attempt for a message (for debugging)
@@ -98,4 +94,50 @@ class MessageRetryManager {
DateTime? getLastRetryTime(String messageId) {
return _lastRetryTimes[messageId];
}
/// Record a successful delivery for a contact and clear any accumulated
/// route failure streak for future sends.
void recordDeliverySuccess(Contact contact) {
_pathFailureStreaks.remove(contact.publicKeyHex);
}
/// Record a permanent route failure for a contact.
///
/// Returns the updated failure streak so callers can decide when to reset
/// the learned path on the radio and in local state.
int recordPathFailure(Contact contact) {
final contactKey = contact.publicKeyHex;
final next = (_pathFailureStreaks[contactKey] ?? 0) + 1;
_pathFailureStreaks[contactKey] = next;
return next;
}
int? getPathFailureStreak(Contact contact) {
return _pathFailureStreaks[contact.publicKeyHex];
}
int _estimateLoRaAirtimeMs(int payloadLenBytes) {
final sf = _defaultLoRaSf;
final bw = _defaultLoRaBwHz.toDouble();
final cr = (_defaultLoRaCr - 4).clamp(1, 4);
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
final symbolMs = ((1 << sf) / bw) * 1000.0;
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
final num =
(8 * payloadLenBytes) -
(4 * sf) +
28 +
(16 * _defaultLoRaCrcEnabled) -
(20 * ih);
final den = 4 * (sf - (2 * de));
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
final payloadSymbols =
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
final payloadMs = payloadSymbols * symbolMs;
return (preambleMs + payloadMs).ceil();
}
}

View File

@@ -46,8 +46,8 @@ class PingTracker {
/// Mark a ping as successful (response received)
/// Should be called when telemetry response arrives
void markPingSuccessful(Uint8List publicKey) {
final String keyHex = _publicKeyToHex(publicKey);
final request = _pendingPings.remove(keyHex);
final requestKey = _findMatchingPendingPingKey(publicKey);
final request = requestKey != null ? _pendingPings.remove(requestKey) : null;
if (request != null) {
request.cancel();
@@ -81,6 +81,23 @@ class PingTracker {
String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
String? _findMatchingPendingPingKey(Uint8List responseKey) {
final responseHex = _publicKeyToHex(responseKey);
if (_pendingPings.containsKey(responseHex)) {
return responseHex;
}
for (final entry in _pendingPings.entries) {
final pendingHex = entry.key;
if (pendingHex.startsWith(responseHex) || responseHex.startsWith(pendingHex)) {
return pendingHex;
}
}
return null;
}
}
/// Internal class to track a single ping request

View File

@@ -9,13 +9,6 @@ typedef RawPacketSender =
required Uint8List payload,
});
typedef FragmentAckWaiter =
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
});
Future<bool> serveCachedSessionFragments<T>({
required String providerLabel,
required String sessionId,
@@ -25,9 +18,7 @@ Future<bool> serveCachedSessionFragments<T>({
required int Function(T fragment) indexOf,
required Uint8List Function(T fragment) encodeBinary,
required RawPacketSender? sendRawPacket,
FragmentAckWaiter? waitForFragmentAck,
Set<int>? requestedIndices,
Duration ackTimeout = const Duration(seconds: 8),
}) async {
if (fragments.isEmpty) {
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
@@ -37,13 +28,13 @@ Future<bool> serveCachedSessionFragments<T>({
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
return false;
}
if (requester.outPathLen < 0) {
if (!requester.routeHasPath) {
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
return false;
}
if (requester.outPathLen > maxDirectPayloadHops) {
if (requester.routeHopCount > maxDirectPayloadHops) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.routeHopCount} hops (max $maxDirectPayloadHops)',
);
return false;
}
@@ -65,24 +56,12 @@ Future<bool> serveCachedSessionFragments<T>({
continue;
}
try {
final ackFuture = waitForFragmentAck?.call(
sessionId: sessionId,
index: index,
timeout: ackTimeout,
);
await sendRawPacket(
contactPath: requester.outPath,
contactPathLen: requester.outPathLen,
contactPathLen: requester.routeEncodedPathLen,
payload: encodeBinary(fragment),
);
servedCount++;
if (ackFuture != null) {
final acked = await ackFuture;
if (!acked) {
debugPrint('⚠️ [$providerLabel] ACK timeout for $sessionId#$index');
return false;
}
}
} catch (e, st) {
debugPrint(
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',

View File

@@ -1,38 +1,57 @@
import '../../models/message.dart';
import '../../utils/image_message_parser.dart';
import '../../utils/voice_message_parser.dart';
class RestoredSessionMetadata {
final Map<String, String> voiceSenderKeyBySession;
final Map<String, String> imageSenderKeyBySession;
final Map<String, ImageEnvelope> imageEnvelopeBySession;
const RestoredSessionMetadata({
required this.voiceSenderKeyBySession,
required this.imageSenderKeyBySession,
required this.imageEnvelopeBySession,
});
}
RestoredSessionMetadata restoreSessionMetadataFromMessages(
Iterable<String> messageTexts,
Iterable<Message> messages,
) {
final voiceSenderKeyBySession = <String, String>{};
final imageSenderKeyBySession = <String, String>{};
final imageEnvelopeBySession = <String, ImageEnvelope>{};
for (final text in messageTexts) {
for (final message in messages) {
final text = message.text;
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
if (voiceEnvelope != null) {
voiceSenderKeyBySession[voiceEnvelope.sessionId] = voiceEnvelope
.senderKey6
.toLowerCase();
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
voiceSenderKeyBySession[voiceEnvelope.sessionId] = senderPrefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
}
final imageEnvelope = ImageEnvelope.tryParse(text);
if (imageEnvelope != null) {
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
final senderPrefix = message.senderPublicKeyPrefix;
if (senderPrefix != null && senderPrefix.length >= 6) {
imageSenderKeyBySession[imageEnvelope.sessionId] = senderPrefix
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
}
}
return RestoredSessionMetadata(
voiceSenderKeyBySession: voiceSenderKeyBySession,
imageSenderKeyBySession: imageSenderKeyBySession,
imageEnvelopeBySession: imageEnvelopeBySession,
);
}

View File

@@ -70,12 +70,6 @@ class ImageProvider with ChangeNotifier {
required Uint8List payload,
})?
sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
ImageProvider() {
_restore();
@@ -102,11 +96,28 @@ class ImageProvider with ChangeNotifier {
return missing;
}
List<int> availableFragmentIndices(String sessionId) {
final outgoing = _outgoing[sessionId];
if (outgoing != null) {
return outgoing.fragments.map((fragment) => fragment.index).toList()
..sort();
}
final session = _sessions[sessionId];
if (session == null) return const [];
final indices = <int>[];
for (var i = 0; i < session.fragments.length; i++) {
if (session.fragments[i] != null) {
indices.add(i);
}
}
return indices;
}
// ── Incoming fragment reception ──────────────────────────────────────────
/// Add a received [fragment]. Creates the session on first fragment using
/// metadata from the fragment itself (requires envelope to have been
/// announced first; if not, defaults width/height to 0 — corrected on save).
/// Add a received [fragment]. New compact fragments rely on prior envelope
/// metadata for total/format, while legacy fragments can still self-describe.
///
/// Returns true when the session just became complete.
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
@@ -116,16 +127,20 @@ class ImageProvider with ChangeNotifier {
);
return false;
}
_sessions.putIfAbsent(
fragment.sessionId,
() => ImageSession(
_sessions.putIfAbsent(fragment.sessionId, () {
if (fragment.total < 1) {
throw StateError(
'Image envelope missing for compact fragment ${fragment.sessionId}',
);
}
return ImageSession(
sessionId: fragment.sessionId,
format: fragment.format,
total: fragment.total,
width: width,
height: height,
),
);
);
});
final session = _sessions[fragment.sessionId]!;
if (fragment.index < session.total) {
@@ -250,21 +265,26 @@ class ImageProvider with ChangeNotifier {
required Contact requester,
Set<int>? requestedIndices,
}) async {
final cached = _outgoing[sessionId];
if (cached == null) {
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId');
final outgoing = _outgoing[sessionId];
final fragments = outgoing != null
? List<ImagePacket>.from(outgoing.fragments)
: _sessions[sessionId]?.fragments.whereType<ImagePacket>().toList() ??
const <ImagePacket>[];
if (fragments.isEmpty) {
debugPrint(
'⚠️ [ImageProvider] No cached or received session for $sessionId',
);
return false;
}
return serveCachedSessionFragments<ImagePacket>(
providerLabel: 'ImageProvider',
sessionId: sessionId,
requester: requester,
fragments: cached.fragments,
fragments: fragments,
maxDirectPayloadHops: maxDirectPayloadHops,
indexOf: (fragment) => fragment.index,
encodeBinary: (fragment) => fragment.encodeBinary(),
sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices,
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,300 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import 'connection_provider.dart';
import 'contacts_provider.dart';
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
class SensorsProvider with ChangeNotifier {
static const Duration _successStateRetention = Duration(minutes: 1);
static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
static const String _fieldSpanKey = 'sensor_field_spans';
static const Set<String> _defaultVisibleFields = <String>{
'voltage',
'battery',
'temperature',
'humidity',
'pressure',
'gps',
};
final List<String> _watchedSensorKeys = <String>[];
final Map<String, SensorRefreshState> _refreshStates =
<String, SensorRefreshState>{};
final Map<String, DateTime> _refreshStateUpdatedAt = <String, DateTime>{};
final Map<String, Set<String>> _visibleFieldsBySensor =
<String, Set<String>>{};
final Map<String, Map<String, int>> _fieldSpansBySensor =
<String, Map<String, int>>{};
bool _isLoaded = false;
bool _isRefreshingAll = false;
SensorsProvider() {
unawaited(_loadWatchedSensors());
}
List<String> get watchedSensorKeys => List.unmodifiable(_watchedSensorKeys);
bool get isLoaded => _isLoaded;
bool get isRefreshingAll => _isRefreshingAll;
SensorRefreshState stateFor(String publicKeyHex) =>
_refreshStates[publicKeyHex] ?? SensorRefreshState.idle;
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);
_watchedSensorKeys
..clear()
..addAll(stored);
_visibleFieldsBySensor.clear();
_fieldSpansBySensor.clear();
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
_visibleFieldsBySensor[entry.key] = (entry.value as List<dynamic>)
.cast<String>()
.toSet();
}
}
if (storedSpansJson != null && storedSpansJson.isNotEmpty) {
final decoded = jsonDecode(storedSpansJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
_fieldSpansBySensor[entry.key] = (entry.value as Map<String, dynamic>)
.map((key, value) => MapEntry(key, value as int));
}
}
for (final key in _watchedSensorKeys) {
_visibleFieldsBySensor.putIfAbsent(
key,
() => Set<String>.from(_defaultVisibleFields),
);
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
}
} catch (e) {
debugPrint('Error loading watched sensors: $e');
} finally {
_isLoaded = true;
notifyListeners();
}
}
Future<void> _persistWatchedSensors() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(_watchedSensorsKey, _watchedSensorKeys);
} catch (e) {
debugPrint('Error saving watched sensors: $e');
}
}
Future<void> _persistVisibleMetrics() async {
try {
final prefs = await SharedPreferences.getInstance();
final encoded = <String, List<String>>{};
for (final entry in _visibleFieldsBySensor.entries) {
encoded[entry.key] = entry.value.toList();
}
await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded));
} catch (e) {
debugPrint('Error saving visible sensor metrics: $e');
}
}
Future<void> _persistFieldSpans() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_fieldSpanKey, jsonEncode(_fieldSpansBySensor));
} catch (e) {
debugPrint('Error saving sensor field spans: $e');
}
}
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
);
bool showsField(String publicKeyHex, String fieldKey) =>
visibleFieldsFor(publicKeyHex).contains(fieldKey);
int fieldSpanFor(String publicKeyHex, String fieldKey) {
final sensorSpans = _fieldSpansBySensor[publicKeyHex];
final span = sensorSpans?[fieldKey] ?? 1;
return span == 2 ? 2 : 1;
}
Future<void> toggleMetric(
String publicKeyHex,
String fieldKey,
bool visible,
) async {
final visibleFields = _visibleFieldsBySensor.putIfAbsent(
publicKeyHex,
() => Set<String>.from(_defaultVisibleFields),
);
if (visible) {
visibleFields.add(fieldKey);
} else {
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
return;
}
visibleFields.remove(fieldKey);
}
await _persistVisibleMetrics();
notifyListeners();
}
Future<void> setFieldSpan(
String publicKeyHex,
String fieldKey,
int span,
) async {
final sensorSpans = _fieldSpansBySensor.putIfAbsent(
publicKeyHex,
() => <String, int>{},
);
sensorSpans[fieldKey] = span == 2 ? 2 : 1;
await _persistFieldSpans();
notifyListeners();
}
bool isWatched(String publicKeyHex) =>
_watchedSensorKeys.contains(publicKeyHex);
Future<void> addSensor(Contact contact) async {
if (!contact.isChat && !contact.isRepeater) {
return;
}
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
return;
}
_watchedSensorKeys.add(contact.publicKeyHex);
await _persistWatchedSensors();
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from(
_defaultVisibleFields,
);
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
await _persistVisibleMetrics();
await _persistFieldSpans();
notifyListeners();
}
Future<void> removeSensor(String publicKeyHex) async {
_watchedSensorKeys.remove(publicKeyHex);
_refreshStates.remove(publicKeyHex);
_refreshStateUpdatedAt.remove(publicKeyHex);
_visibleFieldsBySensor.remove(publicKeyHex);
_fieldSpansBySensor.remove(publicKeyHex);
await _persistWatchedSensors();
await _persistVisibleMetrics();
await _persistFieldSpans();
notifyListeners();
}
List<Contact> availableCandidates(ContactsProvider contactsProvider) {
final candidates = <Contact>[
...contactsProvider.chatContacts,
...contactsProvider.repeaters,
];
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
return candidates;
}
Future<void> refreshAll({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) async {
if (_isRefreshingAll || _watchedSensorKeys.isEmpty) {
return;
}
_isRefreshingAll = true;
notifyListeners();
try {
for (final key in _watchedSensorKeys) {
await refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
}
} finally {
_isRefreshingAll = false;
notifyListeners();
}
}
Future<void> refreshSensor({
required String publicKeyHex,
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) async {
Contact? contact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
contact = entry;
break;
}
}
if (contact == null) {
_setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
return;
}
_setRefreshState(publicKeyHex, SensorRefreshState.refreshing);
final result = await connectionProvider.smartPing(
contactPublicKey: contact.publicKey,
hasPath: contact.hasPath,
);
_setRefreshState(
publicKeyHex,
result.success ? SensorRefreshState.success : SensorRefreshState.timeout,
);
}
void clearExpiredRefreshStates({DateTime? now}) {
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
final keysToClear = <String>[];
for (final entry in _refreshStates.entries) {
if (entry.value != SensorRefreshState.success) {
continue;
}
final updatedAt = _refreshStateUpdatedAt[entry.key];
if (updatedAt == null || !updatedAt.isAfter(cutoff)) {
keysToClear.add(entry.key);
}
}
if (keysToClear.isEmpty) {
return;
}
for (final key in keysToClear) {
_refreshStates.remove(key);
_refreshStateUpdatedAt.remove(key);
}
notifyListeners();
}
void _setRefreshState(String publicKeyHex, SensorRefreshState state) {
_refreshStates[publicKeyHex] = state;
_refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
notifyListeners();
}
}

View File

@@ -71,12 +71,6 @@ class VoiceProvider with ChangeNotifier {
required Uint8List payload,
})?
sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
@@ -131,6 +125,23 @@ class VoiceProvider with ChangeNotifier {
return missing;
}
List<int> availablePacketIndices(String sessionId) {
final outgoing = _outgoingSessions[sessionId];
if (outgoing != null) {
return outgoing.packets.map((packet) => packet.index).toList()..sort();
}
final session = _sessions[sessionId];
if (session == null) return const [];
final indices = <int>[];
for (var i = 0; i < session.packets.length; i++) {
if (session.packets[i] != null) {
indices.add(i);
}
}
return indices;
}
// ── Packet reception ─────────────────────────────────────────────────────
/// Add an incoming [packet] to its session. Creates the session on first packet.
@@ -142,14 +153,18 @@ class VoiceProvider with ChangeNotifier {
);
return false;
}
_sessions.putIfAbsent(
packet.sessionId,
() => VoiceSession(
_sessions.putIfAbsent(packet.sessionId, () {
if (packet.total < 1) {
throw StateError(
'Voice envelope missing for compact packet ${packet.sessionId}',
);
}
return VoiceSession(
sessionId: packet.sessionId,
mode: packet.mode,
total: packet.total,
),
);
);
});
final session = _sessions[packet.sessionId]!;
if (packet.index < session.total) {
@@ -185,6 +200,47 @@ class VoiceProvider with ChangeNotifier {
}
}
void registerEnvelope(VoiceEnvelope envelope) {
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
return;
}
final existing = _sessions[envelope.sessionId];
if (existing == null) {
_sessions[envelope.sessionId] = VoiceSession(
sessionId: envelope.sessionId,
mode: envelope.mode,
total: envelope.total,
);
_persistVoiceData();
notifyListeners();
return;
}
final needsMerge =
existing.total != envelope.total || existing.mode != envelope.mode;
if (!needsMerge) {
notifyListeners();
return;
}
final merged = VoiceSession(
sessionId: envelope.sessionId,
mode: envelope.mode,
total: envelope.total,
);
merged.firstPacketAt = existing.firstPacketAt;
merged.lastPacketAt = existing.lastPacketAt;
for (final packet in existing.packets) {
if (packet == null) continue;
if (packet.index < merged.total) {
merged.packets[packet.index] = packet;
}
}
_sessions[envelope.sessionId] = merged;
_persistVoiceData();
notifyListeners();
}
/// Cache encoded packets for deferred voice serving.
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
if (packets.isEmpty) return;
@@ -201,10 +257,14 @@ class VoiceProvider with ChangeNotifier {
required Contact requester,
Set<int>? requestedIndices,
}) async {
final cached = _outgoingSessions[sessionId];
if (cached == null) {
final outgoing = _outgoingSessions[sessionId];
final packets = outgoing != null
? List<VoicePacket>.from(outgoing.packets)
: _sessions[sessionId]?.packets.whereType<VoicePacket>().toList() ??
const <VoicePacket>[];
if (packets.isEmpty) {
debugPrint(
'⚠️ [VoiceProvider] No cached outgoing session for $sessionId',
'⚠️ [VoiceProvider] No cached or received session for $sessionId',
);
return false;
}
@@ -212,12 +272,11 @@ class VoiceProvider with ChangeNotifier {
providerLabel: 'VoiceProvider',
sessionId: sessionId,
requester: requester,
fragments: cached.packets,
fragments: packets,
maxDirectPayloadHops: maxDirectPayloadHops,
indexOf: (packet) => packet.index,
encodeBinary: (packet) => packet.encodeBinary(),
sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices,
);
}

View File

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

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import '../providers/connection_provider.dart';
import '../services/validation_service.dart';
import '../l10n/app_localizations.dart';
@@ -398,26 +398,51 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
Widget build(BuildContext context) {
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final locationSet =
(deviceInfo.advLat != null && deviceInfo.advLat != 0) ||
(deviceInfo.advLon != null && deviceInfo.advLon != 0);
return Scaffold(
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Device Info Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
children: [
_ConfigHeroCard(
title: deviceInfo.selfName ?? deviceInfo.deviceName ?? 'MeshCore',
subtitle:
'${_getDeviceTypeString(context, deviceInfo.deviceType)}${deviceInfo.semanticVersion ?? deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown}',
chips: [
_StatusChipData(
icon: Icons.bluetooth,
label:
'${AppLocalizations.of(context)!.bleName}: ${deviceInfo.deviceName ?? AppLocalizations.of(context)!.unknown}',
),
_StatusChipData(
icon: Icons.my_location,
label: locationSet ? 'Location ready' : 'Location off',
emphasized: locationSet,
),
_StatusChipData(
icon: Icons.settings_input_antenna,
label: '${_freqController.text} MHz • $_selectedBandwidth',
),
_StatusChipData(
icon: Icons.key,
label: 'FW v${deviceInfo.firmwareVersion?.toString() ?? "?"}',
),
],
),
const SizedBox(height: 20),
_ConfigSectionCard(
title: AppLocalizations.of(context)!.deviceInformation,
subtitle:
'${deviceInfo.manufacturerModel ?? AppLocalizations.of(context)!.unknown}${deviceInfo.firmwareBuildDate ?? AppLocalizations.of(context)!.unknown}',
icon: Icons.memory_rounded,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.deviceInformation,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_InfoRow(
AppLocalizations.of(context)!.bleName,
deviceInfo.deviceName ??
@@ -467,42 +492,75 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
],
),
),
),
const SizedBox(height: 24),
// Public Info Section
Card(
child: Padding(
padding: const EdgeInsets.all(16),
const SizedBox(height: 20),
_ConfigSectionCard(
title: AppLocalizations.of(context)!.publicInfo,
subtitle: AppLocalizations.of(context)!.nameBroadcastInMesh,
icon: Icons.public_rounded,
trailing: FilledButton.icon(
onPressed: _savePublicInfo,
icon: const Icon(Icons.save_outlined),
label: Text(AppLocalizations.of(context)!.save),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context)!.publicInfo,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.45,
),
borderRadius: BorderRadius.circular(18),
),
child: Row(
children: [
Icon(
_telemetryEnabled
? Icons.travel_explore
: Icons.location_disabled,
color: _telemetryEnabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 8),
IconButton.filled(
onPressed: _savePublicInfo,
icon: const Icon(Icons.save),
tooltip: AppLocalizations.of(context)!.save,
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(
context,
)!.telemetryAndLocationSharing,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
_telemetryEnabled
? 'This device advertises position data to the mesh.'
: 'Position broadcasting is currently disabled.',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
],
),
],
),
const SizedBox(width: 12),
Switch(
value: _telemetryEnabled,
onChanged: (value) {
setState(() {
_telemetryEnabled = value;
});
},
),
],
),
),
const SizedBox(height: 16),
// Mesh Network Name
const SizedBox(height: 18),
TextField(
controller: _nameController,
decoration: InputDecoration(
@@ -513,62 +571,21 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
)!.nameBroadcastInMesh,
),
),
const SizedBox(height: 8),
// Telemetry Toggle - Compact version
Row(
children: [
Expanded(
child: Text(
AppLocalizations.of(
context,
)!.telemetryAndLocationSharing,
style: theme.textTheme.bodyMedium,
),
),
Switch(
value: _telemetryEnabled,
onChanged: (value) {
setState(() {
_telemetryEnabled = value;
});
},
),
],
),
// GPS Coordinates (only show if telemetry enabled)
if (_telemetryEnabled) ...[
const SizedBox(height: 12),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
child: _CompactCoordinateField(
controller: _latController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.lat,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
label: AppLocalizations.of(context)!.lat,
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
child: _CompactCoordinateField(
controller: _lonController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!.lon,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
label: AppLocalizations.of(context)!.lon,
),
),
const SizedBox(width: 8),
@@ -585,36 +602,45 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
],
),
),
),
const SizedBox(height: 24),
// Radio Settings Section
Card(
child: Padding(
padding: const EdgeInsets.all(16),
const SizedBox(height: 20),
_ConfigSectionCard(
title: AppLocalizations.of(context)!.radioSettings,
subtitle:
'${_freqController.text} MHz • SF$_selectedSpreadingFactor • CR$_selectedCodingRate',
icon: Icons.settings_input_antenna_rounded,
trailing: FilledButton.icon(
onPressed: _saveRadioSettings,
icon: const Icon(Icons.save_outlined),
label: Text(AppLocalizations.of(context)!.save),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
AppLocalizations.of(context)!.radioSettings,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
Expanded(
child: _RadioMetricTile(
label: AppLocalizations.of(context)!.bandwidth,
value: _selectedBandwidth,
),
),
IconButton.filled(
onPressed: _saveRadioSettings,
icon: const Icon(Icons.save),
tooltip: AppLocalizations.of(context)!.save,
const SizedBox(width: 10),
Expanded(
child: _RadioMetricTile(
label: AppLocalizations.of(context)!.spreadingFactor,
value: 'SF$_selectedSpreadingFactor',
),
),
const SizedBox(width: 10),
Expanded(
child: _RadioMetricTile(
label: AppLocalizations.of(context)!.codingRate,
value: 'CR$_selectedCodingRate',
),
),
],
),
const SizedBox(height: 16),
// LoRa Frequency
TextField(
controller: _freqController,
decoration: InputDecoration(
@@ -629,8 +655,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
),
const SizedBox(height: 16),
// Bandwidth
DropdownButtonFormField<String>(
initialValue: _selectedBandwidth,
decoration: InputDecoration(
@@ -652,8 +676,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
},
),
const SizedBox(height: 16),
// Spreading Factor
DropdownButtonFormField<int>(
initialValue: _selectedSpreadingFactor,
decoration: InputDecoration(
@@ -677,8 +699,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
},
),
const SizedBox(height: 16),
// Coding Rate
DropdownButtonFormField<int>(
initialValue: _selectedCodingRate,
decoration: InputDecoration(
@@ -702,8 +722,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
},
),
const SizedBox(height: 16),
// TX Power
TextField(
controller: _txPowerController,
decoration: InputDecoration(
@@ -715,36 +733,42 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
),
keyboardType: TextInputType.number,
),
// Repeat Mode (firmware v9+)
if (deviceInfo.clientRepeat != null) ...[
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Client Repeat Mode'),
subtitle: deviceInfo.allowedRepeatFreqRanges != null &&
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
? Text(
'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
)
: const Text(
'Repeat packets on behalf of nearby nodes',
),
value: _repeatEnabled,
onChanged: (value) {
setState(() {
_repeatEnabled = value;
});
},
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.45,
),
borderRadius: BorderRadius.circular(18),
),
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Client Repeat Mode'),
subtitle:
deviceInfo.allowedRepeatFreqRanges != null &&
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
? Text(
'Allowed: ${deviceInfo.allowedRepeatFreqRanges!.map((r) => r.lower == r.upper ? '${(r.lower / 1000).toStringAsFixed(3)} MHz' : '${(r.lower / 1000).toStringAsFixed(3)}${(r.upper / 1000).toStringAsFixed(3)} MHz').join(', ')}',
)
: const Text(
'Repeat packets on behalf of nearby nodes',
),
value: _repeatEnabled,
onChanged: (value) {
setState(() {
_repeatEnabled = value;
});
},
),
),
],
],
),
),
),
const SizedBox(height: 24),
],
],
),
),
);
}
@@ -771,6 +795,276 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
}
}
class _ConfigHeroCard extends StatelessWidget {
final String title;
final String subtitle;
final List<_StatusChipData> chips;
const _ConfigHeroCard({
required this.title,
required this.subtitle,
required this.chips,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colorScheme.primaryContainer,
colorScheme.surfaceContainerHighest,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(28),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(18),
),
child: Icon(
Icons.tune_rounded,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(
alpha: 0.82,
),
),
),
],
),
),
],
),
const SizedBox(height: 18),
Wrap(
spacing: 10,
runSpacing: 10,
children: chips.map(_StatusChip.new).toList(),
),
],
),
);
}
}
class _ConfigSectionCard extends StatelessWidget {
final String title;
final String subtitle;
final IconData icon;
final Widget child;
final Widget? trailing;
const _ConfigSectionCard({
required this.title,
required this.subtitle,
required this.icon,
required this.child,
this.trailing,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: colorScheme.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
if (trailing != null) ...[const SizedBox(width: 12), trailing!],
],
),
const SizedBox(height: 18),
child,
],
),
),
);
}
}
class _StatusChipData {
final IconData icon;
final String label;
final bool emphasized;
const _StatusChipData({
required this.icon,
required this.label,
this.emphasized = false,
});
}
class _StatusChip extends StatelessWidget {
final _StatusChipData data;
const _StatusChip(this.data);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final chipColor = data.emphasized
? colorScheme.primary.withValues(alpha: 0.14)
: colorScheme.onPrimaryContainer.withValues(alpha: 0.10);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: chipColor,
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(data.icon, size: 16, color: colorScheme.onPrimaryContainer),
const SizedBox(width: 8),
Text(
data.label,
style: theme.textTheme.labelLarge?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class _CompactCoordinateField extends StatelessWidget {
final TextEditingController controller;
final String label;
const _CompactCoordinateField({
required this.controller,
required this.label,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
signed: true,
),
);
}
}
class _RadioMetricTile extends StatelessWidget {
final String label;
final String value;
const _RadioMetricTile({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 6),
Text(
value,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;

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

@@ -1,10 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
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';
class PacketLogScreen extends StatefulWidget {
final MeshCoreBleService bleService;
@@ -580,6 +586,10 @@ class _PacketLogCard extends StatelessWidget {
),
),
],
if (isRx) ...[
const SizedBox(height: 12),
_DecodedRouteSection(log: log),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
@@ -723,6 +733,259 @@ class _PacketLogCard extends StatelessWidget {
}
}
class _DecodedRouteSection extends StatelessWidget {
final BlePacketLog log;
const _DecodedRouteSection({required this.log});
@override
Widget build(BuildContext context) {
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
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?.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) => _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,
);
},
);
}
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,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.alt_route, size: 16),
SizedBox(width: 6),
Text(
'Mesh Route',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
_FactCard(
icon: Icons.route,
label: 'Payload',
value: _payloadTypeLabel(route.payloadType),
),
_FactCard(
icon: Icons.hub,
label: 'Hops',
value: '${route.hopCount}',
),
_FactCard(
icon: Icons.tag,
label: 'Hash size',
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),
if (path.isEmpty)
Text(
'Direct packet, no hop path attached.',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (var i = 0; i < path.length; i++) ...[
_RouteHopChip(index: i + 1, node: path[i]),
if (i < path.length - 1)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: Icon(Icons.arrow_right_alt, size: 16),
),
],
],
),
],
),
);
}
static String _payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'REQ';
case 0x01:
return 'RESP';
case 0x02:
return 'TXT';
case 0x03:
return 'ACK';
case 0x04:
return 'ADVERT';
case 0x05:
return 'GRP_TXT';
case 0x06:
return 'GRP_DATA';
case 0x07:
return 'ANON_REQ';
case 0x08:
return 'PATH';
case 0x09:
return 'TRACE';
case 0x0A:
return 'MULTIPART';
case 0x0B:
return 'CONTROL';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
}
}
static String _nodeLabel(ResolvedNodeHash node) {
if (node.isOwnNode) {
return '${node.label} (${node.hexLabel})';
}
if (node.matchCount == 0) {
return node.hexLabel;
}
if (node.isUniqueMatch) {
return '${node.label} (${node.hexLabel})';
}
return '${node.label} (${node.hexLabel}, ${node.matchCount} matches)';
}
}
class _RouteHopChip extends StatelessWidget {
final int index;
final ResolvedNodeHash node;
const _RouteHopChip({required this.index, required this.node});
@override
Widget build(BuildContext context) {
final color = node.isOwnNode
? Colors.blue
: node.isUniqueMatch
? Colors.green
: Colors.orange;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Text(
'$index. ${_RouteSection._nodeLabel(node)}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
);
}
}
class _FactCard extends StatelessWidget {
final IconData icon;
final String label;

1115
lib/screens/sensors_tab.dart Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

@@ -15,7 +15,7 @@ class WelcomeWizardScreen extends StatefulWidget {
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
static const int _totalPages = 6;
static const int _totalPages = 5;
@override
void dispose() {
@@ -99,7 +99,6 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
children: [
_buildWelcomePage(context, l10n, colorScheme),
_buildConnectingPage(context, l10n, colorScheme),
_buildSimpleModePage(context, l10n, colorScheme),
_buildChannelPage(context, l10n, colorScheme),
_buildContactsPage(context, l10n, colorScheme),
_buildMapPage(context, l10n, colorScheme),
@@ -182,42 +181,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
title: l10n.wizardConnectingTitle,
description: l10n.wizardConnectingDescription,
features: [
_FeatureItem(
icon: Icons.radio,
text: l10n.wizardConnectingFeature1,
),
_FeatureItem(
icon: Icons.link,
text: l10n.wizardConnectingFeature2,
),
_FeatureItem(
icon: Icons.wifi_off,
text: l10n.wizardConnectingFeature3,
),
],
colorScheme: colorScheme,
);
}
Widget _buildSimpleModePage(
BuildContext context,
AppLocalizations l10n,
ColorScheme colorScheme,
) {
return _buildPage(
icon: Icons.toggle_on,
iconColor: Colors.green,
title: l10n.wizardSimpleModeTitle,
description: l10n.wizardSimpleModeDescription,
features: [
_FeatureItem(
icon: Icons.check_circle_outline,
text: l10n.wizardSimpleModeFeature1,
),
_FeatureItem(
icon: Icons.settings,
text: l10n.wizardSimpleModeFeature2,
),
_FeatureItem(icon: Icons.radio, text: l10n.wizardConnectingFeature1),
_FeatureItem(icon: Icons.link, text: l10n.wizardConnectingFeature2),
_FeatureItem(icon: Icons.wifi_off, text: l10n.wizardConnectingFeature3),
],
colorScheme: colorScheme,
);
@@ -234,18 +200,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
title: l10n.wizardChannelTitle,
description: l10n.wizardChannelDescription,
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.public, text: l10n.wizardChannelFeature1),
_FeatureItem(icon: Icons.groups, text: l10n.wizardChannelFeature2),
_FeatureItem(icon: Icons.send, text: l10n.wizardChannelFeature3),
],
colorScheme: colorScheme,
);
@@ -262,14 +219,8 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
title: l10n.wizardContactsTitle,
description: l10n.wizardContactsDescription,
features: [
_FeatureItem(
icon: Icons.person_add,
text: l10n.wizardContactsFeature1,
),
_FeatureItem(
icon: Icons.chat,
text: l10n.wizardContactsFeature2,
),
_FeatureItem(icon: Icons.person_add, text: l10n.wizardContactsFeature1),
_FeatureItem(icon: Icons.chat, text: l10n.wizardContactsFeature2),
_FeatureItem(
icon: Icons.battery_std,
text: l10n.wizardContactsFeature3,
@@ -290,22 +241,13 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
title: l10n.wizardMapTitle,
description: l10n.wizardMapDescription,
features: [
_FeatureItem(
icon: Icons.location_on,
text: l10n.wizardMapFeature1,
),
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapFeature1),
_FeatureItem(
icon: Icons.person_pin_circle,
text: l10n.wizardMapFeature2,
),
_FeatureItem(
icon: Icons.offline_pin,
text: l10n.wizardMapFeature3,
),
_FeatureItem(
icon: Icons.draw,
text: l10n.wizardMapFeature4,
),
_FeatureItem(icon: Icons.offline_pin, text: l10n.wizardMapFeature3),
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapFeature4),
],
colorScheme: colorScheme,
);
@@ -332,20 +274,16 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
color: iconColor.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
icon,
size: 80,
color: iconColor,
),
child: Icon(icon, size: 80, color: iconColor),
),
const SizedBox(height: 32),
// Title
Text(
title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
@@ -353,36 +291,33 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
Text(
description,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.5,
),
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,
),
...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,
),
),
],
),
)),
),
],
),
),
),
],
const SizedBox(height: 20),
],

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

@@ -0,0 +1,15 @@
import 'package:shared_preferences/shared_preferences.dart';
class DeveloperModeService {
static const String _developerModeKey = 'developer_mode_enabled';
static Future<bool> isEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_developerModeKey) ?? false;
}
static Future<void> setEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_developerModeKey, enabled);
}
}

View File

@@ -15,6 +15,12 @@ class LocalePreferences {
Locale('fr'), // French
Locale('it'), // Italian
Locale('el'), // Greek
Locale('pt'), // Portuguese
Locale('tr'), // Turkish
Locale('pl'), // Polish
Locale('uk'), // Ukrainian
Locale('ru'), // Russian
Locale('zh'), // Chinese
];
/// Get the saved locale or return null to use system locale
@@ -64,6 +70,18 @@ class LocalePreferences {
return 'Italiano';
case 'el':
return 'Greek';
case 'pt':
return 'Portuguese';
case 'tr':
return 'Turkish';
case 'pl':
return 'Polish';
case 'uk':
return 'Ukrainian';
case 'ru':
return 'Русский';
case 'zh':
return '简体中文';
default:
return locale.languageCode;
}
@@ -88,6 +106,18 @@ class LocalePreferences {
return 'Italiano';
case 'el':
return 'Ελληνικά';
case 'pt':
return 'Português';
case 'tr':
return 'Türkçe';
case 'pl':
return 'Polski';
case 'uk':
return 'Українська';
case 'ru':
return 'Русский';
case 'zh':
return '简体中文';
default:
return locale.languageCode;
}

View File

@@ -41,6 +41,12 @@ class LocationTrackingService {
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
static const String _prefKeyLastLat = 'background_last_lat';
static const String _prefKeyLastLon = 'background_last_lon';
static const String _prefKeyFastLocationEnabled =
'fast_location_updates_enabled';
static const String _prefKeyFastMovementThreshold =
'fast_location_movement_threshold_meters';
static const String _prefKeyFastActiveCadence =
'fast_location_active_cadence_seconds';
// ============================================================================
// Configuration Properties
@@ -58,6 +64,15 @@ class LocationTrackingService {
/// GPS update distance filter for position stream
double gpsUpdateDistance = 10.0;
/// Whether private fast GPS updates are enabled
bool fastLocationUpdatesEnabled = false;
/// Distance threshold for fast GPS updates
double fastLocationMovementThresholdMeters = 10.0;
/// Cadence for active-use fast GPS updates
int fastLocationActiveCadenceSeconds = 10;
// ============================================================================
// State Properties
// ============================================================================
@@ -84,6 +99,11 @@ class LocationTrackingService {
/// Position stream subscription
StreamSubscription<Position>? _positionSubscription;
Timer? _fastLocationTimer;
bool _isFastLocationActiveUse = false;
DateTime? _lastFastLocationSentAt;
Position? _lastFastLocationSentPosition;
// ============================================================================
// Callback Properties
// ============================================================================
@@ -100,6 +120,9 @@ class LocationTrackingService {
/// Called when tracking state changes
void Function(bool isTracking)? onTrackingStateChanged;
/// Called when a fast private GPS update should be sent
void Function(Position position, String reason)? onFastLocationUpdate;
// ============================================================================
// Initialization
// ============================================================================
@@ -178,7 +201,9 @@ class LocationTrackingService {
for (int attempt = 0; attempt <= retryCount; attempt++) {
try {
if (attempt > 0) {
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount');
debugPrint(
'🔄 [LocationTracking] Retry attempt $attempt/$retryCount',
);
// Exponential backoff: wait 2^attempt seconds before retry
await Future.delayed(Duration(seconds: 1 << attempt));
}
@@ -192,21 +217,29 @@ class LocationTrackingService {
currentPosition = position;
if (attempt > 0) {
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries');
debugPrint(
'✅ [LocationTracking] Position acquired after $attempt retries',
);
}
return position;
} catch (e) {
final isLastAttempt = attempt == retryCount;
if (isLastAttempt) {
debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e');
debugPrint(
'❌ [LocationTracking] Failed to get position after $retryCount retries: $e',
);
// Only call error callback on final failure, and make it user-friendly
if (e.toString().contains('TimeoutException')) {
onError?.call('GPS signal weak. Position stream will continue trying...');
onError?.call(
'GPS signal weak. Position stream will continue trying...',
);
} else {
onError?.call('Failed to get GPS position. Check device settings.');
}
} else {
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e');
debugPrint(
'⚠️ [LocationTracking] Position attempt $attempt failed: $e',
);
}
if (isLastAttempt) {
@@ -244,16 +277,16 @@ class LocationTrackingService {
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
Future<bool> startTracking({double? distanceThreshold}) async {
if (!_isInitialized) {
debugPrint(
'⚠️ [LocationTracking] Service not initialized',
);
debugPrint('⚠️ [LocationTracking] Service not initialized');
onError?.call('Location tracking service not initialized');
return false;
}
// Allow tracking without BLE connection - broadcasts will be skipped
if (_bleService == null || !_bleService!.isConnected) {
debugPrint(' [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)');
debugPrint(
' [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)',
);
}
// Check permissions
@@ -271,17 +304,20 @@ class LocationTrackingService {
// Try to get initial position in background (non-blocking)
// This will populate currentPosition but won't block tracking startup
getCurrentPosition(
timeLimit: const Duration(seconds: 10),
retryCount: 1,
).then((position) {
if (position != null) {
debugPrint('✅ [LocationTracking] Initial position acquired in background');
}
}).catchError((error) {
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error');
// Not critical - position stream will eventually provide position
});
getCurrentPosition(timeLimit: const Duration(seconds: 10), retryCount: 1)
.then((position) {
if (position != null) {
debugPrint(
'✅ [LocationTracking] Initial position acquired in background',
);
}
})
.catchError((error) {
debugPrint(
'⚠️ [LocationTracking] Background initial position failed: $error',
);
// Not critical - position stream will eventually provide position
});
// Start position stream immediately (don't wait for initial position)
try {
@@ -296,6 +332,7 @@ class LocationTrackingService {
isTracking = true;
onTrackingStateChanged?.call(true);
_refreshFastLocationTimer();
debugPrint(
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
@@ -318,6 +355,7 @@ class LocationTrackingService {
isTracking = false;
onTrackingStateChanged?.call(false);
_refreshFastLocationTimer();
// Reset first position flag so next connection starts fresh
_firstPositionSet = false;
@@ -361,6 +399,8 @@ class LocationTrackingService {
// Notify listeners
onPositionUpdate?.call(position);
_evaluateFastLocationMovement(position);
// SPECIAL CASE: First stable position after connection
// Set lat/lon on device WITHOUT broadcasting to mesh network
if (!_firstPositionSet) {
@@ -378,12 +418,16 @@ class LocationTrackingService {
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
void _setInitialPosition(Position position) async {
if (_bleService == null || !_bleService!.isConnected) {
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected');
debugPrint(
'⚠️ [LocationTracking] Cannot set initial position: BLE not connected',
);
return;
}
try {
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)');
debugPrint(
'📍 [LocationTracking] Setting initial position (no broadcast)',
);
// Update device's advertised location WITHOUT sending advertisement
await _bleService!.setAdvertLatLon(
@@ -414,7 +458,94 @@ class LocationTrackingService {
void _checkAndBroadcast(Position position) {
// Automatic broadcasting disabled
// Use the manual advert button instead
debugPrint(' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)');
debugPrint(
' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)',
);
}
void setFastLocationActiveUse(bool isActive) {
if (_isFastLocationActiveUse == isActive) return;
_isFastLocationActiveUse = isActive;
_refreshFastLocationTimer();
}
Future<void> setFastLocationUpdatesEnabled(bool enabled) async {
fastLocationUpdatesEnabled = enabled;
await saveSettings();
_refreshFastLocationTimer();
}
Future<void> updateFastLocationMovementThreshold(double meters) async {
fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0);
await saveSettings();
}
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
fastLocationActiveCadenceSeconds = seconds.clamp(5, 60);
await saveSettings();
_refreshFastLocationTimer();
}
void _evaluateFastLocationMovement(Position position) {
if (!fastLocationUpdatesEnabled) return;
final previous = _lastFastLocationSentPosition;
if (previous == null) {
_emitFastLocationUpdate(position, reason: 'initial');
return;
}
final distance = Geolocator.distanceBetween(
previous.latitude,
previous.longitude,
position.latitude,
position.longitude,
);
if (distance >= fastLocationMovementThresholdMeters) {
_emitFastLocationUpdate(position, reason: 'movement');
}
}
void _refreshFastLocationTimer() {
_fastLocationTimer?.cancel();
_fastLocationTimer = null;
if (!isTracking ||
!fastLocationUpdatesEnabled ||
!_isFastLocationActiveUse) {
return;
}
_fastLocationTimer = Timer.periodic(
Duration(seconds: fastLocationActiveCadenceSeconds),
(_) {
final position = currentPosition;
if (position == null) return;
_emitFastLocationUpdate(position, reason: 'active_use');
},
);
}
void _emitFastLocationUpdate(Position position, {required String reason}) {
if (!fastLocationUpdatesEnabled) return;
final now = DateTime.now();
final previous = _lastFastLocationSentPosition;
final previousTime = _lastFastLocationSentAt;
if (previous != null && previousTime != null) {
final distance = Geolocator.distanceBetween(
previous.latitude,
previous.longitude,
position.latitude,
position.longitude,
);
final elapsedMs = now.difference(previousTime).inMilliseconds;
if (distance < 1.0 && elapsedMs < 3000) {
return;
}
}
_lastFastLocationSentPosition = position;
_lastFastLocationSentAt = now;
onFastLocationUpdate?.call(position, reason);
}
// ============================================================================
@@ -457,7 +588,9 @@ class LocationTrackingService {
await _bleService!.sendSelfAdvert(floodMode: true);
debugPrint('✅ [LocationTracking] Manual broadcast successful');
debugPrint(' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s');
debugPrint(
' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s',
);
onBroadcastSent?.call(position);
return true;
@@ -480,12 +613,24 @@ class LocationTrackingService {
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
fastLocationUpdatesEnabled =
prefs.getBool(_prefKeyFastLocationEnabled) ?? false;
fastLocationMovementThresholdMeters =
(prefs.getDouble(_prefKeyFastMovementThreshold) ?? gpsUpdateDistance)
.clamp(1.0, 1000.0);
fastLocationActiveCadenceSeconds =
(prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60);
debugPrint('✅ [LocationTracking] Settings loaded');
debugPrint(' Min distance: ${minDistanceMeters}m');
debugPrint(' Max distance: ${maxDistanceMeters}m');
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
debugPrint(' Fast updates enabled: $fastLocationUpdatesEnabled');
debugPrint(
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
);
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
}
/// Save settings to SharedPreferences
@@ -497,6 +642,18 @@ class LocationTrackingService {
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
await prefs.setBool(_prefKeyEnabled, isTracking);
await prefs.setBool(
_prefKeyFastLocationEnabled,
fastLocationUpdatesEnabled,
);
await prefs.setDouble(
_prefKeyFastMovementThreshold,
fastLocationMovementThresholdMeters,
);
await prefs.setInt(
_prefKeyFastActiveCadence,
fastLocationActiveCadenceSeconds,
);
debugPrint('✅ [LocationTracking] Settings saved');
}
@@ -509,6 +666,7 @@ class LocationTrackingService {
void dispose() {
debugPrint('🗑️ [LocationTracking] Disposing service');
_positionSubscription?.cancel();
_fastLocationTimer?.cancel();
_positionSubscription = null;
_bleService = null;
_isInitialized = false;

View File

@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
import 'package:geolocator/geolocator.dart';
import '../models/contact.dart';
import '../models/sar_marker.dart';
import '../widgets/common/contact_avatar.dart';
import '../widgets/map/location_pointer.dart';
/// Centralized service for map marker management.
@@ -77,9 +78,15 @@ class MapMarkerService {
// Marker icon
Container(
decoration: BoxDecoration(
color: getContactMarkerColor(contact, context),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
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),
@@ -88,17 +95,8 @@ class MapMarkerService {
),
],
),
padding: const EdgeInsets.all(6),
child: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 18),
)
: Icon(
getContactMarkerIcon(contact),
color: Colors.white,
size: 18,
),
padding: const EdgeInsets.all(2),
child: ContactAvatar(contact: contact, radius: 16),
),
const SizedBox(height: 2),
// Name label (without emoji)

View File

@@ -1,273 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:mbtiles/mbtiles.dart';
/// Metadata information extracted from an MBTiles file
class MbtilesMetadata {
final String name;
final String? description;
final String? version;
final String? attribution;
final String? bounds; // "minLon,minLat,maxLon,maxLat"
final String? center; // "lon,lat,zoom"
final int? minZoom;
final int? maxZoom;
final String? format; // "pbf", "png", "jpg", etc.
final String? type; // "overlay", "baselayer"
final String? json; // Additional metadata JSON
final File file;
final int fileSize;
const MbtilesMetadata({
required this.name,
this.description,
this.version,
this.attribution,
this.bounds,
this.center,
this.minZoom,
this.maxZoom,
this.format,
this.type,
this.json,
required this.file,
required this.fileSize,
});
/// Check if this is a vector tile MBTiles file
bool get isVector => format == 'pbf' || format == 'mvt';
/// Parse bounds string into [minLon, minLat, maxLon, maxLat]
List<double>? get boundsCoordinates {
if (bounds == null) return null;
try {
final parts = bounds!.split(',');
if (parts.length != 4) return null;
return parts.map((s) => double.parse(s.trim())).toList();
} catch (e) {
debugPrint('Error parsing bounds: $e');
return null;
}
}
/// Parse center string into [lon, lat, zoom]
List<double>? get centerCoordinates {
if (center == null) return null;
try {
final parts = center!.split(',');
if (parts.length < 2) return null;
return parts.map((s) => double.parse(s.trim())).toList();
} catch (e) {
debugPrint('Error parsing center: $e');
return null;
}
}
/// Get file size in human-readable format
String get fileSizeFormatted {
if (fileSize < 1024) {
return '$fileSize B';
} else if (fileSize < 1024 * 1024) {
return '${(fileSize / 1024).toStringAsFixed(1)} KB';
} else if (fileSize < 1024 * 1024 * 1024) {
return '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(fileSize / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
}
}
/// Service for managing MBTiles files for offline vector maps
class MbtilesService {
static const String _mbtilesDirectory = 'offline_maps';
/// Get the directory where MBTiles files are stored
Future<Directory> getMbtilesDirectory() async {
final appDocDir = await getApplicationDocumentsDirectory();
final mbtilesDir = Directory('${appDocDir.path}/$_mbtilesDirectory');
// Create directory if it doesn't exist
if (!await mbtilesDir.exists()) {
await mbtilesDir.create(recursive: true);
}
return mbtilesDir;
}
/// List all MBTiles files in the offline maps directory
Future<List<File>> listMbtilesFiles() async {
final dir = await getMbtilesDirectory();
try {
final files = await dir
.list()
.where((entity) => entity is File && entity.path.endsWith('.mbtiles'))
.map((entity) => entity as File)
.toList();
return files;
} catch (e) {
debugPrint('Error listing MBTiles files: $e');
return [];
}
}
/// Get metadata from an MBTiles file
Future<MbtilesMetadata?> getMetadata(File file) async {
try {
// Check if file exists
if (!await file.exists()) {
debugPrint('MBTiles file does not exist: ${file.path}');
return null;
}
// Get file size
final fileSize = await file.length();
// Open MBTiles file
final mbtiles = MbTiles(mbtilesPath: file.path);
// Get metadata from MBTiles
final metadata = mbtiles.getMetadata();
// Convert bounds object to string if available
String? boundsStr;
if (metadata.bounds != null) {
boundsStr = metadata.bounds.toString();
}
return MbtilesMetadata(
name: metadata.name,
description: metadata.description,
version: metadata.version?.toString(),
attribution: null, // Not available in new API
bounds: boundsStr,
center: null, // Not available in new API
minZoom: metadata.minZoom?.toInt(),
maxZoom: metadata.maxZoom?.toInt(),
format: metadata.format,
type: metadata.type?.name,
json: null, // Not available in new API
file: file,
fileSize: fileSize,
);
} catch (e) {
debugPrint('Error reading MBTiles metadata from ${file.path}: $e');
return null;
}
}
/// Get metadata for all MBTiles files
Future<List<MbtilesMetadata>> getAllMetadata() async {
final files = await listMbtilesFiles();
final metadataList = <MbtilesMetadata>[];
for (final file in files) {
final metadata = await getMetadata(file);
if (metadata != null) {
metadataList.add(metadata);
}
}
return metadataList;
}
/// Import an MBTiles file from an external location
Future<File?> importMbtilesFile(String sourcePath) async {
try {
final sourceFile = File(sourcePath);
// Verify source file exists
if (!await sourceFile.exists()) {
debugPrint('Source file does not exist: $sourcePath');
return null;
}
// Get destination directory
final destDir = await getMbtilesDirectory();
final fileName = _getFileName(sourceFile);
final destPath = '${destDir.path}/$fileName';
// Copy file to destination
final destFile = await sourceFile.copy(destPath);
debugPrint('Imported MBTiles file to: $destPath');
return destFile;
} catch (e) {
debugPrint('Error importing MBTiles file: $e');
return null;
}
}
/// Delete an MBTiles file
Future<bool> deleteMbtilesFile(File file) async {
try {
if (await file.exists()) {
await file.delete();
debugPrint('Deleted MBTiles file: ${file.path}');
return true;
}
return false;
} catch (e) {
debugPrint('Error deleting MBTiles file: $e');
return false;
}
}
/// Check if data in MBTiles is gzip compressed
Future<bool> isGzipCompressed(File file) async {
try {
// Open MBTiles and check a sample tile
final mbtiles = MbTiles(mbtilesPath: file.path);
// Try to get metadata to check for compression hints
final metadata = mbtiles.getMetadata();
final format = metadata.format;
// For Geofabrik files, format is 'pbf' and data is gzipped
// We can infer this from common patterns, but ideally we'd check actual tile data
if (format == 'pbf') {
// Geofabrik MBTiles are typically gzipped
// Could also check tile data headers, but this is a reasonable heuristic
return true;
}
return false;
} catch (e) {
debugPrint('Error checking gzip compression: $e');
return false;
}
}
/// Determine the vector tile schema from metadata
String? getVectorSchema(MbtilesMetadata metadata) {
// Try to infer schema from metadata
final json = metadata.json;
if (json != null) {
if (json.contains('shortbread')) {
return 'shortbread';
} else if (json.contains('openmaptiles')) {
return 'openmaptiles';
}
}
// Check description
final description = metadata.description?.toLowerCase();
if (description != null) {
if (description.contains('shortbread')) {
return 'shortbread';
} else if (description.contains('openmaptiles')) {
return 'openmaptiles';
}
}
// Default to unknown
return null;
}
/// Helper: Get file name from path
String _getFileName(File file) {
return file.path.split(Platform.pathSeparator).last;
}
}

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;
@@ -31,23 +33,39 @@ 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 String _nodesEndpoint =
'https://api.meshcore.nz/api/v1/map/nodes';
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}) async {
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!;
now.difference(_cachedAt!) < cacheTtl) {
return cached;
}
final response = await http
if (!allowNetwork) {
return cached;
}
final response = await (client ?? http.Client())
.get(Uri.parse(_nodesEndpoint))
.timeout(const Duration(seconds: 12));
.timeout(traceTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Map nodes API returned ${response.statusCode}');
}
@@ -58,11 +76,136 @@ 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.latitude != 0 && n.longitude != 0,
)
.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.latitude != 0 && n.longitude != 0,
)
.toList();
_cachedNodes = nodes;
_cachedAt = cachedAt;
return nodes;
}
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
return nodes.isNotEmpty;
}
static Future<void> syncInBackgroundIfStale({
Duration cacheTtl = _cacheTtl,
http.Client? client,
}) async {
final now = DateTime.now();
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
return;
}
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
if (cached.isNotEmpty && _cachedAt != null) {
return;
}
if (_ongoingSync != null) {
return _ongoingSync!;
}
_ongoingSync = () async {
try {
await fetchNodes(
forceRefresh: true,
cacheTtl: cacheTtl,
allowNetwork: true,
client: client,
);
} catch (_) {
// Background refresh is best-effort only.
} finally {
_ongoingSync = null;
}
}();
return _ongoingSync!;
}
static Future<void> clearCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_cacheKey);
await prefs.remove(_cacheTimestampKey);
_cachedNodes = null;
_cachedAt = null;
}
static Future<DateTime?> cachedAt() async {
if (_cachedAt != null) return _cachedAt;
final prefs = await SharedPreferences.getInstance();
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
if (cachedAtMs == null) return null;
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
return _cachedAt;
}
static 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

@@ -7,6 +7,7 @@ class MessageDestinationPreferences {
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
/// Destination types
static const String destinationTypeAll = 'all';
static const String destinationTypeChannel = 'channel';
static const String destinationTypeContact = 'contact';
static const String destinationTypeRoom = 'room';
@@ -24,14 +25,11 @@ class MessageDestinationPreferences {
final publicKey = prefs.getString(_recipientPublicKeyKey);
return {
'type': type,
'publicKey': ?publicKey,
};
return {'type': type, 'publicKey': ?publicKey};
}
/// Save the selected destination
/// [type] - one of: destinationTypeChannel, destinationTypeContact, destinationTypeRoom
/// [type] - one of: destinationTypeAll, destinationTypeChannel, destinationTypeContact, destinationTypeRoom
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
static Future<void> setDestination(
String type, {
@@ -58,6 +56,8 @@ class MessageDestinationPreferences {
/// Get display name for destination type
static String getDestinationTypeName(String type) {
switch (type) {
case destinationTypeAll:
return 'All';
case destinationTypeChannel:
return 'Channel';
case destinationTypeContact:

View File

@@ -2,15 +2,33 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/message.dart';
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 'package:latlong2/latlong.dart';
/// Service for persisting messages to local storage
class MessageStorageService {
static const String _messagesKey = 'stored_messages';
static const String _messageContactLocationsKey =
'stored_message_contact_locations';
static const String _messageReceptionDetailsKey =
'stored_message_reception_details';
static const String _messageTransferDetailsKey =
'stored_message_transfer_details';
static const String _messageRouteMetadataKey =
'stored_message_route_metadata';
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
/// Save messages to persistent storage
Future<void> saveMessages(List<Message> messages) async {
Future<void> saveMessages(
List<Message> messages, {
Map<String, MessageContactLocation> messageContactLocations = const {},
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
Map<String, MessageTransferDetails> messageTransferDetails = const {},
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
}) async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -24,6 +42,49 @@ class MessageStorageService {
final jsonString = jsonEncode(limitedList);
await prefs.setString(_messagesKey, jsonString);
final retainedMessageIds = limitedList
.map((entry) => entry['id'] as String)
.toSet();
final locationJson = <String, dynamic>{};
final receptionJson = <String, dynamic>{};
final transferJson = <String, dynamic>{};
final routeMetadataJson = <String, dynamic>{};
for (final entry in messageContactLocations.entries) {
if (retainedMessageIds.contains(entry.key)) {
locationJson[entry.key] = entry.value.toJson();
}
}
for (final entry in messageReceptionDetails.entries) {
if (retainedMessageIds.contains(entry.key)) {
receptionJson[entry.key] = entry.value.toJson();
}
}
for (final entry in messageTransferDetails.entries) {
if (retainedMessageIds.contains(entry.key)) {
transferJson[entry.key] = entry.value.toJson();
}
}
for (final entry in messageRouteMetadata.entries) {
if (retainedMessageIds.contains(entry.key)) {
routeMetadataJson[entry.key] = entry.value.toJson();
}
}
await prefs.setString(
_messageContactLocationsKey,
jsonEncode(locationJson),
);
await prefs.setString(
_messageReceptionDetailsKey,
jsonEncode(receptionJson),
);
await prefs.setString(
_messageTransferDetailsKey,
jsonEncode(transferJson),
);
await prefs.setString(
_messageRouteMetadataKey,
jsonEncode(routeMetadataJson),
);
debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
@@ -33,6 +94,122 @@ class MessageStorageService {
}
}
Future<Map<String, MessageContactLocation>>
loadMessageContactLocations() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageContactLocationsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageContactLocation>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final snapshot = MessageContactLocation.fromJson(value);
if (snapshot != null) {
result[entry.key] = snapshot;
}
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading contact locations: $e');
return const {};
}
}
Future<Map<String, MessageReceptionDetails>>
loadMessageReceptionDetails() 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 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;
}
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
return const {};
}
}
Future<Map<String, MessageTransferDetails>>
loadMessageTransferDetails() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageTransferDetailsKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageTransferDetails>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
final details = MessageTransferDetails.fromJson(value);
if (details != null) {
result[entry.key] = details;
}
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading transfer details: $e');
return const {};
}
}
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async {
try {
final prefs = await SharedPreferences.getInstance();
final jsonString = prefs.getString(_messageRouteMetadataKey);
if (jsonString == null || jsonString.isEmpty) {
return const {};
}
final decoded = jsonDecode(jsonString);
if (decoded is! Map<String, dynamic>) {
return const {};
}
final result = <String, MessageRouteMetadata>{};
for (final entry in decoded.entries) {
final value = entry.value;
if (value is! Map<String, dynamic>) continue;
result[entry.key] = MessageRouteMetadata.fromJson(value);
}
return result;
} catch (e) {
debugPrint('❌ [MessageStorage] Error loading route metadata: $e');
return const {};
}
}
/// Load messages from persistent storage
Future<List<Message>> loadMessages() async {
try {
@@ -66,6 +243,10 @@ class MessageStorageService {
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);
debugPrint('✅ [MessageStorage] Cleared all stored messages');
} catch (e) {
debugPrint('❌ [MessageStorage] Error clearing messages: $e');

View File

@@ -0,0 +1,46 @@
import 'package:shared_preferences/shared_preferences.dart';
class MessagingRoutePreferences {
static const bool defaultAutoRouteRotationEnabled = false;
static const bool defaultClearPathOnMaxRetry = false;
static const bool defaultNearestRelayFallbackEnabled = true;
static const String _autoRouteRotationKey =
'messaging_auto_route_rotation_enabled';
static const String _clearPathOnMaxRetryKey =
'messaging_clear_path_on_max_retry';
static const String _nearestRelayFallbackKey =
'messaging_nearest_relay_fallback_enabled';
static Future<bool> getAutoRouteRotationEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_autoRouteRotationKey) ??
defaultAutoRouteRotationEnabled;
}
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_autoRouteRotationKey, enabled);
}
static Future<bool> getClearPathOnMaxRetry() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry;
}
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_clearPathOnMaxRetryKey, enabled);
}
static Future<bool> getNearestRelayFallbackEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_nearestRelayFallbackKey) ??
defaultNearestRelayFallbackEnabled;
}
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_nearestRelayFallbackKey, enabled);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:geolocator/geolocator.dart';
import '../models/contact.dart';
class NearestRouterSelector {
const NearestRouterSelector();
Contact? select({
required Position? senderPosition,
required List<Contact> repeaters,
required Contact recipient,
}) {
if (senderPosition == null) {
return null;
}
final eligible = repeaters.where((contact) {
if (contact.publicKeyHex == recipient.publicKeyHex) {
return false;
}
if (!contact.isRecentlySeen) {
return false;
}
return contact.displayLocation != null;
}).toList();
if (eligible.isEmpty) {
return null;
}
eligible.sort((a, b) {
final locationA = a.displayLocation!;
final locationB = b.displayLocation!;
final distanceA = Geolocator.distanceBetween(
senderPosition.latitude,
senderPosition.longitude,
locationA.latitude,
locationA.longitude,
);
final distanceB = Geolocator.distanceBetween(
senderPosition.latitude,
senderPosition.longitude,
locationB.latitude,
locationB.longitude,
);
final distanceCompare = distanceA.compareTo(distanceB);
if (distanceCompare != 0) {
return distanceCompare;
}
final advertCompare = b.lastAdvert.compareTo(a.lastAdvert);
if (advertCompare != 0) {
return advertCompare;
}
final hopCompare = a.routeHopCount.compareTo(b.routeHopCount);
if (hopCompare != 0) {
return hopCompare;
}
final nameCompare = a.advName.compareTo(b.advName);
if (nameCompare != 0) {
return nameCompare;
}
return a.publicKeyHex.compareTo(b.publicKeyHex);
});
return eligible.first;
}
}

View File

@@ -58,7 +58,6 @@ class NotificationService {
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
requestCriticalPermission: true, // For urgent SAR notifications
);
// Combined initialization settings
@@ -100,8 +99,6 @@ class NotificationService {
alert: true,
badge: true,
sound: true,
critical:
true, // Request critical alert permission for urgent SAR notifications
);
_permissionGranted = granted ?? false;
debugPrint(
@@ -274,8 +271,7 @@ class NotificationService {
badgeNumber: 1,
threadIdentifier: 'sar_markers',
categoryIdentifier: 'SAR_ALERT',
interruptionLevel:
InterruptionLevel.critical, // Critical alert (bypasses silent mode)
interruptionLevel: InterruptionLevel.timeSensitive,
);
// Combined notification details

View File

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

View File

@@ -0,0 +1,273 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../models/path_history.dart';
import '../models/path_selection.dart';
class PathHistoryService {
static const String _storageKey = 'contact_path_history_v1';
static const int _maxDirectPaths = 20;
static const int _topRotationCount = 3;
final Map<String, ContactPathHistory> _cache = {};
bool _isLoaded = false;
Future<void> initialize() async {
if (_isLoaded) return;
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey);
if (raw == null || raw.isEmpty) {
_isLoaded = true;
return;
}
try {
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value);
}
}
}
} catch (error) {
debugPrint('⚠️ [PathHistoryService] Failed to load history: $error');
}
_isLoaded = true;
}
Future<void> recordLearnedPath(Contact contact) async {
await initialize();
if (!contact.routeHasPath || contact.routeHopCount <= 0) {
return;
}
final history = _historyFor(contact.publicKeyHex);
final signature = _signature(contact.routePathBytes);
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: contact.routePathBytes.toList(),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
);
await _saveHistory(
contact.publicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
Future<void> recordReceivedBytePath(
String contactPublicKeyHex,
List<int> pathBytes,
int hashSize,
) async {
await initialize();
if (pathBytes.isEmpty) {
return;
}
if (hashSize < 1 || hashSize > 3) {
return;
}
if (pathBytes.length % hashSize != 0) {
return;
}
final history = _historyFor(contactPublicKeyHex);
final signature = pathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: List<int>.from(pathBytes),
hopCount: pathBytes.length ~/ hashSize,
hashSize: hashSize,
successCount: existing?.successCount ?? 0,
failureCount: existing?.failureCount ?? 0,
lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0,
lastUsedAt: DateTime.now(),
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
Future<PathSelection> getSelectionForContact(
Contact contact, {
required bool autoRouteRotationEnabled,
}) async {
await initialize();
await recordLearnedPath(contact);
if (contact.routeHasPath && contact.routeHopCount > 0) {
return PathSelection(
mode: PathSelectionMode.directCurrent,
pathBytes: Uint8List.fromList(contact.routePathBytes),
hopCount: contact.routeHopCount,
hashSize: contact.routeHashSize,
);
}
if (!autoRouteRotationEnabled) {
return PathSelection.flood();
}
final history = _historyFor(contact.publicKeyHex);
final ranked = List<PathRecord>.from(history.directPaths)
..sort(_comparePathRecords);
final topPaths = ranked.take(_topRotationCount).toList();
if (topPaths.isEmpty) {
final nextFloodHistory = history.copyWith(
rotationIndex: history.rotationIndex + 1,
);
await _saveHistory(contact.publicKeyHex, nextFloodHistory);
return PathSelection.flood();
}
final selections =
topPaths
.map(
(record) => PathSelection(
mode: PathSelectionMode.directHistorical,
pathBytes: Uint8List.fromList(record.pathBytes),
hopCount: record.hopCount,
hashSize: record.hashSize,
),
)
.toList()
..add(PathSelection.flood());
final index = history.rotationIndex % selections.length;
final updatedHistory = history.copyWith(
rotationIndex: history.rotationIndex + 1,
);
await _saveHistory(contact.publicKeyHex, updatedHistory);
return selections[index];
}
Future<void> recordPathResult(
String contactPublicKeyHex,
PathSelection selection, {
required bool success,
int? roundTripTimeMs,
}) async {
await initialize();
final history = _historyFor(contactPublicKeyHex);
if (selection.usesFlood) {
final current = history.floodStats;
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
floodStats: current.copyWith(
successCount: current.successCount + (success ? 1 : 0),
failureCount: current.failureCount + (success ? 0 : 1),
lastRoundTripTimeMs: success
? (roundTripTimeMs ?? current.lastRoundTripTimeMs)
: current.lastRoundTripTimeMs,
lastUsedAt: DateTime.now(),
),
),
);
return;
}
final signature = _signature(selection.pathBytes);
final existing = _findDirectPath(history.directPaths, signature);
final updated = PathRecord(
pathBytes: selection.pathBytes.toList(),
hopCount: selection.hopCount,
hashSize: selection.hashSize,
successCount: (existing?.successCount ?? 0) + (success ? 1 : 0),
failureCount: (existing?.failureCount ?? 0) + (success ? 0 : 1),
lastRoundTripTimeMs: success
? (roundTripTimeMs ?? existing?.lastRoundTripTimeMs ?? 0)
: (existing?.lastRoundTripTimeMs ?? 0),
lastUsedAt: DateTime.now(),
);
await _saveHistory(
contactPublicKeyHex,
history.copyWith(
directPaths: _upsertDirectPath(history.directPaths, updated),
),
);
}
ContactPathHistory historyFor(String contactPublicKeyHex) {
return _cache[contactPublicKeyHex] ??
ContactPathHistory.empty(contactPublicKeyHex);
}
ContactPathHistory _historyFor(String contactPublicKeyHex) {
return _cache.putIfAbsent(
contactPublicKeyHex,
() => ContactPathHistory.empty(contactPublicKeyHex),
);
}
Future<void> _saveHistory(
String contactPublicKeyHex,
ContactPathHistory history,
) async {
_cache[contactPublicKeyHex] = history;
final prefs = await SharedPreferences.getInstance();
final payload = <String, dynamic>{};
for (final entry in _cache.entries) {
payload[entry.key] = entry.value.toJson();
}
await prefs.setString(_storageKey, jsonEncode(payload));
}
List<PathRecord> _upsertDirectPath(
List<PathRecord> existing,
PathRecord updatedRecord,
) {
final updated = List<PathRecord>.from(existing)
..removeWhere((record) => record.signature == updatedRecord.signature)
..insert(0, updatedRecord);
if (updated.length > _maxDirectPaths) {
return updated.take(_maxDirectPaths).toList();
}
return updated;
}
int _comparePathRecords(PathRecord a, PathRecord b) {
final successRateCompare = b.successRate.compareTo(a.successRate);
if (successRateCompare != 0) return successRateCompare;
final successCountCompare = b.successCount.compareTo(a.successCount);
if (successCountCompare != 0) return successCountCompare;
final aRtt = a.lastRoundTripTimeMs == 0 ? 1 << 30 : a.lastRoundTripTimeMs;
final bRtt = b.lastRoundTripTimeMs == 0 ? 1 << 30 : b.lastRoundTripTimeMs;
final rttCompare = aRtt.compareTo(bRtt);
if (rttCompare != 0) return rttCompare;
return b.lastUsedAt.compareTo(a.lastUsedAt);
}
PathRecord? _findDirectPath(List<PathRecord> records, String signature) {
for (final record in records) {
if (record.signature == signature) {
return record;
}
}
return null;
}
String _signature(Uint8List bytes) =>
bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}

View File

@@ -0,0 +1,26 @@
import 'package:shared_preferences/shared_preferences.dart';
class RouteHashPreferences {
static const String _hashSizeKey = 'route_hash_size';
static const int defaultHashSize = 1;
static Future<int> getHashSize() async {
final prefs = await SharedPreferences.getInstance();
final value = prefs.getInt(_hashSizeKey) ?? defaultHashSize;
return _normalize(value);
}
static Future<void> setHashSize(int value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_hashSizeKey, _normalize(value));
}
static int normalizeSync(int value) => _normalize(value);
static int _normalize(int value) {
if (value < 1 || value > 3) {
return defaultHashSize;
}
return value;
}
}

View File

@@ -1,284 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart';
import 'package:vector_map_tiles_mbtiles/vector_map_tiles_mbtiles.dart';
import 'package:mbtiles/mbtiles.dart';
import '../models/map_layer.dart';
class TileCacheService {
static const String _storeName = 'meshcore_sar_tiles';
// Global flag to ensure ObjectBox is only initialized once
static bool _objectBoxInitialized = false;
static final _initLock = <String, Future<void>>{};
late final FMTCStore _store;
bool _isInitialized = false;
bool _isDownloading = false;
Future<void> initialize() async {
if (_isInitialized) return;
// Ensure we only initialize ObjectBox once globally
if (!_objectBoxInitialized) {
// Use a lock to prevent concurrent initialization attempts
final initFuture = _initLock.putIfAbsent('objectbox', () async {
try {
await FMTCObjectBoxBackend().initialise();
_objectBoxInitialized = true;
} catch (e) {
// Already initialized or error - that's okay
_objectBoxInitialized = true;
}
});
await initFuture;
}
try {
_store = FMTCStore(_storeName);
await _store.manage.create();
_isInitialized = true;
} catch (e) {
// Store might already exist
_store = FMTCStore(_storeName);
_isInitialized = true;
}
}
FMTCTileProvider getTileProvider(MapLayer layer) {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
return FMTCTileProvider(
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
cachedValidDuration: const Duration(days: 30),
);
}
/// Get tile provider for WMS layers with caching support
/// WMS layers require special handling because they use WMSTileLayerOptions
FMTCTileProvider getTileProviderForWms(MapLayer layer) {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
if (!layer.isWms) {
throw ArgumentError('Layer must be a WMS layer');
}
// Return the same cached tile provider
// The WMS URL construction is handled by flutter_map's WMSTileLayerOptions
return FMTCTileProvider(
stores: {_storeName: BrowseStoreStrategy.readUpdateCreate},
loadingStrategy: BrowseLoadingStrategy.cacheFirst,
cachedValidDuration: const Duration(days: 30),
);
}
Future<void> downloadRegion({
required MapLayer layer,
required LatLngBounds bounds,
required int minZoom,
required int maxZoom,
Function(double progress)? onProgress,
}) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
if (_isDownloading) {
throw StateError('A download is already in progress. Cancel it first.');
}
_isDownloading = true;
try {
final region = RectangleRegion(bounds);
final downloadable = region.toDownloadable(
minZoom: minZoom,
maxZoom: maxZoom,
options: TileLayer(urlTemplate: layer.urlTemplate),
);
final download = _store.download.startForeground(region: downloadable);
await for (final progress in download.downloadProgress) {
if (onProgress != null && progress.maxTilesCount > 0) {
// Use attemptedTilesCount instead of successfulTilesCount
// attemptedTilesCount includes successful + buffered + skipped tiles
final percentage = progress.percentageProgress;
debugPrint(
'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})',
);
onProgress(percentage);
}
}
} finally {
_isDownloading = false;
}
}
Future<void> cancelDownload() async {
if (!_isInitialized) return;
await _store.download.cancel();
}
Future<void> clearCache() async {
if (!_isInitialized) return;
await _store.manage.delete();
await _store.manage.create();
}
Future<int> getCachedTileCount() async {
if (!_isInitialized) return 0;
final stats = await _store.stats.length;
return stats;
}
Future<double> getCacheSizeMB() async {
if (!_isInitialized) return 0.0;
final stats = await _store.stats.size;
return stats / (1024 * 1024);
}
Future<List<String>> getAvailableStores() async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
final stores = await FMTCRoot.stats.storesAvailable;
return stores.map((store) => store.storeName).toList();
}
Future<Map<String, dynamic>> getStoreStats() async {
if (!_isInitialized) return {};
final length = await _store.stats.length;
final size = await _store.stats.all.then((a) => a.size);
return {
'tileCount': length,
'sizeMB': size / 1024,
'storeName': _storeName,
};
}
/// Get vector tile provider for MBTiles layers
MbTilesVectorTileProvider? getVectorTileProvider(MapLayer layer) {
if (!layer.isVector || layer.mbtilesFile == null) {
return null;
}
try {
final mbtiles = MbTiles(
mbtilesPath: layer.mbtilesFile!.path,
gzip: layer.isGzipped ?? false,
);
return MbTilesVectorTileProvider(
mbtiles: mbtiles,
);
} catch (e) {
debugPrint('Error creating vector tile provider: $e');
return null;
}
}
/// Export the current tile cache store to an archive file
///
/// [outputPath] - Full path where the archive should be saved (e.g., '/path/to/export.fmtc')
///
/// Returns the number of tiles exported
Future<int> exportStore(String outputPath) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
try {
final external = FMTCRoot.external(pathToArchive: outputPath);
final result = await external.export(storeNames: [_storeName]);
debugPrint('Export completed: $result tiles exported to $outputPath');
return result;
} catch (e) {
debugPrint('Error exporting store: $e');
rethrow;
}
}
/// Import a tile cache store from an archive file
///
/// [filePath] - Path to the .fmtc archive file to import
/// [storeNames] - Optional list of store names to import (null = import all)
/// [strategy] - Conflict resolution strategy (default: merge)
///
/// Returns a map with import statistics (e.g., tile count, stores imported)
Future<Map<String, dynamic>> importStore(
String filePath, {
List<String>? storeNames,
ImportConflictStrategy strategy = ImportConflictStrategy.merge,
}) async {
if (!_isInitialized) {
throw StateError(
'TileCacheService not initialized. Call initialize() first.',
);
}
try {
final external = FMTCRoot.external(pathToArchive: filePath);
final result = external.import(storeNames: storeNames, strategy: strategy);
// Wait for the import to complete and get tile count
final tileCount = await result.complete;
// Wait for store states
final storesToStates = await result.storesToStates;
debugPrint('Import completed: $tileCount tiles imported, ${storesToStates.length} stores');
// Count successful stores (those that weren't skipped)
final successfulCount = storesToStates.values.where((state) => state.name != null).length;
return {
'successfulStores': successfulCount,
'tileCount': tileCount,
'storesToStates': storesToStates,
};
} catch (e) {
debugPrint('Error importing store: $e');
rethrow;
}
}
/// List all stores available in an archive file without importing
///
/// [filePath] - Path to the .fmtc archive file to inspect
///
/// Returns a list of store names contained in the archive
Future<List<String>> listArchiveStores(String filePath) async {
try {
final external = FMTCRoot.external(pathToArchive: filePath);
final stores = await external.listStores;
debugPrint('Archive contains ${stores.length} stores: $stores');
return stores;
} catch (e) {
debugPrint('Error listing archive stores: $e');
rethrow;
}
}
void dispose() {
_isInitialized = false;
}
}

View File

@@ -18,7 +18,7 @@ class VoiceRecorderService {
/// Request microphone permission. Returns true if granted.
Future<bool> requestPermission() async {
return _recorder.hasPermission();
return _recorder.hasPermission(request: true);
}
/// Start capturing PCM audio.
@@ -38,9 +38,7 @@ class VoiceRecorderService {
throw StateError('VoiceRecorderService: already recording');
}
_controller = StreamController<Int16List>(
onCancel: () => _stopInternal(),
);
_controller = StreamController<Int16List>(onCancel: () => _stopInternal());
_isRecording = true;
_startRecording(
@@ -167,17 +165,17 @@ class _VoiceDynamicsProcessor {
required int sampleRate,
required bool enableCompressor,
required bool enableLimiter,
}) : _enableCompressor = enableCompressor,
_enableLimiter = enableLimiter,
_compressor = _SimpleCompressor(
sampleRate: sampleRate.toDouble(),
thresholdDb: -18.0,
ratio: 2.5,
attackMs: 8.0,
releaseMs: 120.0,
makeupGainDb: 4.0,
),
_limiter = _PeakLimiter(ceilingDb: -1.0);
}) : _enableCompressor = enableCompressor,
_enableLimiter = enableLimiter,
_compressor = _SimpleCompressor(
sampleRate: sampleRate.toDouble(),
thresholdDb: -18.0,
ratio: 2.5,
attackMs: 8.0,
releaseMs: 120.0,
makeupGainDb: 4.0,
),
_limiter = _PeakLimiter(ceilingDb: -1.0);
Int16List process(Int16List input) {
final output = Int16List(input.length);
@@ -214,11 +212,11 @@ class _SimpleCompressor {
required double attackMs,
required double releaseMs,
required double makeupGainDb,
}) : _thresholdDb = thresholdDb,
_ratio = ratio,
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
}) : _thresholdDb = thresholdDb,
_ratio = ratio,
_makeupGain = math.pow(10.0, makeupGainDb / 20.0).toDouble(),
_attackCoeff = math.exp(-1.0 / (sampleRate * (attackMs / 1000.0))),
_releaseCoeff = math.exp(-1.0 / (sampleRate * (releaseMs / 1000.0)));
double process(double x) {
final absX = x.abs();
@@ -266,14 +264,14 @@ class _VoiceBandPassFilter {
required int sampleRate,
required double lowCutHz,
required double highCutHz,
}) : _highPass = _BiquadFilter.highPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: lowCutHz,
),
_lowPass = _BiquadFilter.lowPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: highCutHz,
);
}) : _highPass = _BiquadFilter.highPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: lowCutHz,
),
_lowPass = _BiquadFilter.lowPass(
sampleRate: sampleRate.toDouble(),
cutoffHz: highCutHz,
);
Int16List process(Int16List input) {
final output = Int16List(input.length);
@@ -306,11 +304,11 @@ class _BiquadFilter {
required double b2,
required double a1,
required double a2,
}) : _b0 = b0,
_b1 = b1,
_b2 = b2,
_a1 = a1,
_a2 = a2;
}) : _b0 = b0,
_b1 = b1,
_b2 = b2,
_a1 = a1,
_a2 = a2;
factory _BiquadFilter.lowPass({
required double sampleRate,

View File

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

View File

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

View File

@@ -0,0 +1,69 @@
import 'dart:typed_data';
class FastGpsPacket {
static const int magic = 0x47; // 'G'
static const int _payloadLength = 19;
// Store coordinates in microdegrees. This preserves sub-meter precision,
// which comfortably satisfies the meter-accuracy requirement.
static const double coordinateScale = 1e6;
final String senderKey6;
final double latitude;
final double longitude;
final int timestampSeconds;
const FastGpsPacket({
required this.senderKey6,
required this.latitude,
required this.longitude,
required this.timestampSeconds,
});
static bool isFastGpsBinary(Uint8List payload) =>
payload.length == _payloadLength && payload[0] == magic;
static FastGpsPacket? tryParseBinary(Uint8List payload) {
if (!isFastGpsBinary(payload)) return null;
final key6 = payload
.sublist(1, 7)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final data = ByteData.sublistView(payload);
final latitude = data.getInt32(7, Endian.little) / coordinateScale;
final longitude = data.getInt32(11, Endian.little) / coordinateScale;
final timestampSeconds = data.getUint32(15, Endian.little);
if (!_isValidCoordinate(latitude, longitude)) {
return null;
}
return FastGpsPacket(
senderKey6: key6,
latitude: latitude,
longitude: longitude,
timestampSeconds: timestampSeconds,
);
}
Uint8List encodeBinary() {
final out = Uint8List(_payloadLength);
final data = ByteData.sublistView(out);
out[0] = magic;
for (var i = 0; i < 6; i++) {
out[1 + i] = int.parse(senderKey6.substring(i * 2, i * 2 + 2), radix: 16);
}
data.setInt32(7, (latitude * coordinateScale).round(), Endian.little);
data.setInt32(11, (longitude * coordinateScale).round(), Endian.little);
data.setUint32(15, timestampSeconds, Endian.little);
return out;
}
static bool _isValidCoordinate(double latitude, double longitude) {
if (!latitude.isFinite || !longitude.isFinite) return false;
return latitude >= -90.0 &&
latitude <= 90.0 &&
longitude >= -180.0 &&
longitude <= 180.0;
}
}

View File

@@ -4,7 +4,7 @@ const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE
const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen
const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
const int _imagePacketHeaderBytes = 8; // image packet binary header in payload
const int _imagePacketHeaderBytes = 6; // image packet binary header in payload
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
@@ -31,7 +31,7 @@ enum ImageFormat {
/// A single binary fragment of a compressed image.
///
/// Binary format (direct contacts, via pushRawData / cmdSendRawData):
/// [0x49 'I'][sessionId:4B][fmt:1B][idx:1B][total:1B][imageData...]
/// [0x49 'I'][sessionId:4B][idx:1B][imageData...]
///
/// Legacy default is 152 data bytes per fragment.
class ImagePacket {
@@ -50,7 +50,7 @@ class ImagePacket {
});
static const int _magic = 0x49; // 'I'
static const int _headerLen = 8; // magic(1)+session(4)+fmt(1)+idx(1)+total(1)
static const int _headerLen = 6; // magic(1)+session(4)+idx(1)
static const int maxDataBytes =
152; // Conservative default for compatibility.
@@ -65,16 +65,14 @@ class ImagePacket {
.sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final fmtId = payload[5];
final index = payload[6];
final total = payload[7];
if (total < 1) return null;
final index = payload[5];
final data = payload.sublist(_headerLen);
return ImagePacket(
sessionId: sessionId,
format: ImageFormat.fromId(fmtId),
format: ImageFormat.avif,
index: index,
total: total,
data: payload.sublist(_headerLen),
total: 0,
data: data,
);
} catch (_) {
return null;
@@ -92,16 +90,16 @@ class ImagePacket {
final out = Uint8List(_headerLen + data.length);
out[0] = _magic;
out.setRange(1, 5, sessionBytes);
out[5] = format.id;
out[6] = index;
out[7] = total;
out[5] = index;
out.setRange(_headerLen, out.length, data);
return out;
}
@override
String toString() =>
'ImagePacket($sessionId ${format.label} [$index/${total - 1}] ${data.length}B)';
String toString() {
final suffix = total > 0 ? ' ${format.label} [$index/${total - 1}]' : ' [$index]';
return 'ImagePacket($sessionId$suffix ${data.length}B)';
}
}
/// Compute the maximum safe image data bytes for a direct route path.
@@ -246,11 +244,11 @@ int _resolveBandwidthHz(int? rawBw) {
/// Envelope announcing image availability (control plane).
///
/// Text format:
/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
/// IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
/// Example:
/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8
/// IE4:deadbeef:0:7:3k:3k:t6
class ImageEnvelope {
static const String _prefix = 'IE2:';
static const String _prefixV4 = 'IE4:';
final String sessionId; // 8 hex chars
final ImageFormat format;
@@ -258,8 +256,6 @@ class ImageEnvelope {
final int width;
final int height;
final int sizeBytes; // total compressed image size
final String senderKey6; // 12 hex chars (6 bytes)
final int timestampSec;
final int version;
const ImageEnvelope({
@@ -269,18 +265,16 @@ class ImageEnvelope {
required this.width,
required this.height,
required this.sizeBytes,
required this.senderKey6,
required this.timestampSec,
this.version = 2,
this.version = 4,
});
static bool isEnvelope(String text) => text.startsWith(_prefix);
static bool isEnvelope(String text) => text.startsWith(_prefixV4);
static ImageEnvelope? tryParse(String text) {
if (!isEnvelope(text)) return null;
final body = text.substring(_prefix.length);
final body = text.substring(_prefixV4.length);
final parts = body.split(':');
if (parts.length != 8) return null;
if (parts.length != 6) return null;
try {
final sid = _decodeSessionId(parts[0]);
final fmtId = _parseInt(parts[1], base36: true);
@@ -288,16 +282,12 @@ class ImageEnvelope {
final w = _parseInt(parts[3], base36: true);
final h = _parseInt(parts[4], base36: true);
final bytes = _parseInt(parts[5], base36: true);
final senderKey6 = parts[6];
final ts = _parseInt(parts[7], base36: true);
if (sid == null) return null;
if (fmtId == null) return null;
if (total == null || total < 1 || total > 255) return null;
if (w == null || h == null || w < 1 || h < 1) return null;
if (bytes == null || bytes < 1) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
if (ts == null || ts <= 0) return null;
return ImageEnvelope(
sessionId: sid,
@@ -306,9 +296,7 @@ class ImageEnvelope {
width: w,
height: h,
sizeBytes: bytes,
senderKey6: senderKey6.toLowerCase(),
timestampSec: ts,
version: 2,
version: 4,
);
} catch (_) {
return null;
@@ -316,27 +304,25 @@ class ImageEnvelope {
}
String encode() =>
'$_prefix${_encodeSessionId(sessionId)}:'
'$_prefixV4${_encodeSessionId(sessionId)}:'
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
'${_toBase36(height)}:${_toBase36(sizeBytes)}:'
'${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
'${_toBase36(height)}:${_toBase36(sizeBytes)}';
}
/// Direct request to fetch image fragments (control plane).
///
/// Text format:
/// IR2:{sid}:{want}:{requesterKey6}:{ts}
/// IR4:{sid}:{want}:{requesterKey6}
/// Example:
/// IR2:deadbeef:a:aabbccddeeff:s44wea
/// IR4:deadbeef:a:aabbccddeeff
class ImageFetchRequest {
static const String _prefix = 'IR2:';
static const String _prefixV4 = 'IR4:';
static const int _binaryMagic = 0x69; // 'i'
final String sessionId;
final String want; // 'all' or 'missing'
final List<int> missingIndices;
final String requesterKey6; // 12 hex chars
final int timestampSec;
final int version;
const ImageFetchRequest({
@@ -344,24 +330,22 @@ class ImageFetchRequest {
this.want = 'all',
this.missingIndices = const [],
required this.requesterKey6,
required this.timestampSec,
this.version = 2,
this.version = 4,
});
static bool isRequest(String text) => text.startsWith(_prefix);
static bool isRequest(String text) => text.startsWith(_prefixV4);
static bool isRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
static ImageFetchRequest? tryParse(String text) {
if (!isRequest(text)) return null;
final body = text.substring(_prefix.length);
final body = text.substring(_prefixV4.length);
final parts = body.split(':');
if (parts.length != 4) return null;
if (parts.length != 3) return null;
try {
final sid = _decodeSessionId(parts[0]);
final wantToken = parts[1];
final requesterKey6 = parts[2];
final ts = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a'
? 'all'
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
@@ -377,15 +361,13 @@ class ImageFetchRequest {
return null;
}
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
if (ts == null || ts <= 0) return null;
return ImageFetchRequest(
sessionId: sid,
want: normalizedWant,
missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts,
version: 2,
version: 4,
);
} catch (_) {
return null;
@@ -394,7 +376,7 @@ class ImageFetchRequest {
static ImageFetchRequest? tryParseBinary(Uint8List payload) {
if (!isRequestBinary(payload)) return null;
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
if (payload.length < 13) return null; // magic+sid+flags+key6+count
try {
final sid = payload
.sublist(1, 5)
@@ -407,25 +389,19 @@ class ImageFetchRequest {
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final ts =
(payload[12] << 24) |
(payload[13] << 16) |
(payload[14] << 8) |
payload[15];
final missingCount = payload[16];
if (payload.length != 17 + missingCount) return null;
final missingCount = payload[12];
if (payload.length != 13 + missingCount) return null;
final wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[];
for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]);
missing.add(payload[13 + i]);
}
return ImageFetchRequest(
sessionId: sid,
want: wantMissing ? 'missing' : 'all',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: ts,
version: 2,
version: 4,
);
} catch (_) {
return null;
@@ -436,7 +412,7 @@ class ImageFetchRequest {
final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want);
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
return '$_prefixV4${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
}
Uint8List encodeBinary() {
@@ -455,7 +431,7 @@ class ImageFetchRequest {
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[];
final out = Uint8List(17 + missing.length);
final out = Uint8List(13 + missing.length);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
@@ -467,13 +443,9 @@ class ImageFetchRequest {
radix: 16,
);
}
out[12] = (timestampSec >> 24) & 0xFF;
out[13] = (timestampSec >> 16) & 0xFF;
out[14] = (timestampSec >> 8) & 0xFF;
out[15] = timestampSec & 0xFF;
out[16] = missing.length;
out[12] = missing.length;
for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i];
out[13 + i] = missing[i];
}
return out;
}

View File

@@ -0,0 +1,25 @@
String formatPlusCode(double lat, double lon) {
const base = '23456789CFGHJMPQRVWX';
var normalizedLat = (lat + 90) / 180;
var normalizedLon = (lon + 180) / 360;
final buffer = StringBuffer();
for (var i = 0; i < 8; i++) {
if (i == 4) {
buffer.write('+');
}
final latDigit = (normalizedLat * 20).floor() % 20;
final lonDigit = (normalizedLon * 20).floor() % 20;
buffer
..write(base[latDigit])
..write(base[lonDigit]);
normalizedLat = (normalizedLat * 20) % 1;
normalizedLon = (normalizedLon * 20) % 1;
}
return buffer.toString();
}

View File

@@ -0,0 +1,229 @@
import 'dart:typed_data';
import '../models/contact.dart';
class DecodedLogRxRoute {
final int payloadType;
final int pathDescriptor;
final List<int> pathBytes;
final int hashSize;
const DecodedLogRxRoute({
required this.payloadType,
required this.pathDescriptor,
required this.pathBytes,
required this.hashSize,
});
List<String> get hopHashes =>
LogRxRouteDecoder.splitHopHashes(pathBytes, hashSize: hashSize);
int get hopCount => hopHashes.length;
String? get originalSenderHashHex =>
hopHashes.isEmpty ? null : hopHashes.first;
}
class ResolvedNodeHash {
final String hashHex;
final String label;
final bool isOwnNode;
final bool isUniqueMatch;
final int matchCount;
const ResolvedNodeHash({
required this.hashHex,
required this.label,
required this.isOwnNode,
required this.isUniqueMatch,
required this.matchCount,
});
String get hexLabel => '0x${hashHex.toUpperCase()}';
}
class LogRxRouteDecoder {
const LogRxRouteDecoder._();
static DecodedLogRxRoute? decode(
Uint8List rawData, {
int? preferredHashSize,
}) {
if (rawData.length < 5 || rawData[0] != 0x88) return null;
final rawPacketData = rawData.sublist(3);
if (rawPacketData.length < 2) return null;
final header = rawPacketData[0];
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
var index = 1;
if (routeType == 0x00 || routeType == 0x03) {
if (rawPacketData.length < index + 5) return null;
index += 4;
}
if (rawPacketData.length <= index) return null;
final pathDescriptor = rawPacketData[index++];
final pathMode = (pathDescriptor & 0xFF) >> 6;
final pathByteLen = pathMode == 0
? pathDescriptor
: descriptorByteLength(pathDescriptor);
if (pathByteLen == null || rawPacketData.length < index + pathByteLen) {
return null;
}
final pathBytes = rawPacketData.sublist(index, index + pathByteLen);
final hashSize = pathMode == 0
? inferHashSize(pathBytes, preferredHashSize: preferredHashSize)
: (descriptorHashSize(pathDescriptor) ??
inferHashSize(pathBytes, preferredHashSize: preferredHashSize));
return DecodedLogRxRoute(
payloadType: payloadType,
pathDescriptor: pathDescriptor,
pathBytes: pathBytes,
hashSize: hashSize,
);
}
static int? descriptorHashSize(int pathDescriptor) {
final normalized = pathDescriptor & 0xFF;
final mode = normalized >> 6;
if (mode == 3) return null;
return mode + 1;
}
static int? descriptorHopCount(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
if (hashSize == null) return null;
return (pathDescriptor & 0xFF) & 0x3F;
}
static int? descriptorByteLength(int pathDescriptor) {
final hashSize = descriptorHashSize(pathDescriptor);
final hopCount = descriptorHopCount(pathDescriptor);
if (hashSize == null || hopCount == null) return null;
final byteLen = hopCount * hashSize;
return byteLen <= 64 ? byteLen : null;
}
static int inferHashSize(List<int> pathBytes, {int? preferredHashSize}) {
if (pathBytes.isEmpty) return 1;
final normalizedPreferred =
preferredHashSize != null &&
preferredHashSize >= 1 &&
preferredHashSize <= 3
? preferredHashSize
: null;
if (normalizedPreferred != null &&
pathBytes.length % normalizedPreferred == 0) {
return normalizedPreferred;
}
for (final candidate in const [3, 2, 1]) {
if (pathBytes.length % candidate == 0) {
return candidate;
}
}
return 1;
}
static List<String> splitHopHashes(
List<int> pathBytes, {
required int hashSize,
}) {
if (pathBytes.isEmpty) return const [];
if (hashSize < 1 || hashSize > 3 || pathBytes.length % hashSize != 0) {
return pathBytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.toList();
}
final hops = <String>[];
for (var index = 0; index < pathBytes.length; index += hashSize) {
hops.add(
pathBytes
.sublist(index, index + hashSize)
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join(),
);
}
return hops;
}
static ResolvedNodeHash resolveHash(
String hashHex, {
required Iterable<Contact> contacts,
Uint8List? ownPublicKey,
String? ownName,
}) {
final normalizedHashHex = hashHex.toLowerCase();
final ownKeyHex = _bytesToHex(ownPublicKey);
if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) {
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
? '$ownName (you)'
: 'You';
return ResolvedNodeHash(
hashHex: normalizedHashHex,
label: ownLabel,
isOwnNode: true,
isUniqueMatch: true,
matchCount: 1,
);
}
final matches = contacts.where((contact) {
return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex);
}).toList();
if (matches.isEmpty) {
return ResolvedNodeHash(
hashHex: normalizedHashHex,
label: 'Unknown',
isOwnNode: false,
isUniqueMatch: false,
matchCount: 0,
);
}
if (matches.length == 1) {
return ResolvedNodeHash(
hashHex: normalizedHashHex,
label: matches.first.displayName,
isOwnNode: false,
isUniqueMatch: true,
matchCount: 1,
);
}
final candidateNames = matches
.map((contact) => contact.displayName)
.where((name) => name.trim().isNotEmpty)
.take(2)
.join(', ');
final extraCount = matches.length - 2;
final label = candidateNames.isEmpty
? '${matches.length} contacts'
: extraCount > 0
? '$candidateNames +$extraCount'
: candidateNames;
return ResolvedNodeHash(
hashHex: normalizedHashHex,
label: label,
isOwnNode: false,
isUniqueMatch: false,
matchCount: matches.length,
);
}
static String? _bytesToHex(Uint8List? bytes) {
if (bytes == null || bytes.isEmpty) return null;
return bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
}
}

View File

@@ -0,0 +1,168 @@
import 'dart:typed_data';
const int _swarmMagic = 0x6d; // 'm'
const int _swarmKindRequest = 0x01;
const int _swarmKindAvailability = 0x02;
class MediaSwarmRequest {
final String mediaType;
final String sessionId;
final String requesterKey6;
final List<int> missingIndices;
const MediaSwarmRequest({
required this.mediaType,
required this.sessionId,
required this.requesterKey6,
this.missingIndices = const [],
});
bool get requestsAll => missingIndices.isEmpty;
Uint8List encodeBinary() {
final normalizedMissing = missingIndices.toSet().toList()..sort();
final out = Uint8List(14 + normalizedMissing.length);
out[0] = _swarmMagic;
out[1] = _swarmKindRequest;
out[2] = _encodeMediaType(mediaType);
_writeSessionId(out, 3, sessionId);
_writeKey6(out, 7, requesterKey6);
out[13] = normalizedMissing.length;
for (var i = 0; i < normalizedMissing.length; i++) {
out[14 + i] = normalizedMissing[i];
}
return out;
}
static MediaSwarmRequest? tryParseBinary(Uint8List payload) {
if (payload.length < 14 ||
payload[0] != _swarmMagic ||
payload[1] != _swarmKindRequest) {
return null;
}
final mediaType = _decodeMediaType(payload[2]);
if (mediaType == null) return null;
final missingCount = payload[13];
if (payload.length != 14 + missingCount) {
return null;
}
return MediaSwarmRequest(
mediaType: mediaType,
sessionId: _readSessionId(payload, 3),
requesterKey6: _readKey6(payload, 7),
missingIndices: payload.sublist(14),
);
}
}
class MediaSwarmAvailability {
final String mediaType;
final String sessionId;
final String requesterKey6;
final String responderKey6;
final List<int> availableIndices;
const MediaSwarmAvailability({
required this.mediaType,
required this.sessionId,
required this.requesterKey6,
required this.responderKey6,
required this.availableIndices,
});
bool get servesAll => availableIndices.isEmpty;
Uint8List encodeBinary() {
final normalizedAvailable = availableIndices.toSet().toList()..sort();
final out = Uint8List(20 + normalizedAvailable.length);
out[0] = _swarmMagic;
out[1] = _swarmKindAvailability;
out[2] = _encodeMediaType(mediaType);
_writeSessionId(out, 3, sessionId);
_writeKey6(out, 7, requesterKey6);
_writeKey6(out, 13, responderKey6);
out[19] = normalizedAvailable.length;
for (var i = 0; i < normalizedAvailable.length; i++) {
out[20 + i] = normalizedAvailable[i];
}
return out;
}
static MediaSwarmAvailability? tryParseBinary(Uint8List payload) {
if (payload.length < 20 ||
payload[0] != _swarmMagic ||
payload[1] != _swarmKindAvailability) {
return null;
}
final mediaType = _decodeMediaType(payload[2]);
if (mediaType == null) return null;
final availableCount = payload[19];
if (payload.length != 20 + availableCount) {
return null;
}
return MediaSwarmAvailability(
mediaType: mediaType,
sessionId: _readSessionId(payload, 3),
requesterKey6: _readKey6(payload, 7),
responderKey6: _readKey6(payload, 13),
availableIndices: payload.sublist(20),
);
}
}
int _encodeMediaType(String mediaType) {
return switch (mediaType) {
'voice' => 0x01,
'image' => 0x02,
_ => throw ArgumentError.value(mediaType, 'mediaType'),
};
}
String? _decodeMediaType(int raw) {
return switch (raw) {
0x01 => 'voice',
0x02 => 'image',
_ => null,
};
}
void _writeSessionId(Uint8List out, int offset, String sessionId) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionId)) {
throw ArgumentError.value(sessionId, 'sessionId', 'Expected 8 hex chars');
}
for (var i = 0; i < 4; i++) {
out[offset + i] = int.parse(
sessionId.substring(i * 2, i * 2 + 2),
radix: 16,
);
}
}
String _readSessionId(Uint8List payload, int offset) {
return payload
.sublist(offset, offset + 4)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
void _writeKey6(Uint8List out, int offset, String key6) {
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(key6)) {
throw ArgumentError.value(key6, 'key6', 'Expected 12 hex chars');
}
for (var i = 0; i < 6; i++) {
out[offset + i] = int.parse(key6.substring(i * 2, i * 2 + 2), radix: 16);
}
}
String _readKey6(Uint8List payload, int offset) {
return payload
.sublist(offset, offset + 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}

View File

@@ -0,0 +1,149 @@
import '../models/message.dart';
import 'image_message_parser.dart';
import 'voice_message_parser.dart';
const int _defaultLoRaSf = 10;
const int _defaultLoRaCr = 5;
const int _defaultLoRaBwHz = 250000;
const int _defaultLoRaPreambleSymbols = 8;
const int _defaultLoRaCrcEnabled = 1;
const int _defaultLoRaExplicitHeader = 1;
const double _defaultAirtimeBudgetFactor = 1.0;
const int _meshPacketHeaderBytes = 2;
const int _textFrameBaseBytes = 10;
Duration estimateMessageTransmitDuration(
Message message, {
int? radioBw,
int? radioSf,
int? radioCr,
}) {
final imageEnvelope = ImageEnvelope.tryParse(message.text);
if (imageEnvelope != null) {
return estimateImageTransmitDuration(
fragmentCount: imageEnvelope.total,
sizeBytes: imageEnvelope.sizeBytes,
pathLen: message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
}
final voiceEnvelope = VoiceEnvelope.tryParseText(message.text);
if (voiceEnvelope != null) {
return estimateVoiceTransmitDuration(
mode: voiceEnvelope.mode,
packetCount: voiceEnvelope.total,
durationMs: voiceEnvelope.durationMs,
pathLen: message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
}
final voicePacket = VoicePacket.tryParseText(message.text);
if (voicePacket != null) {
return estimateVoiceTransmitDuration(
mode: voicePacket.mode,
packetCount: voicePacket.total,
durationMs: voicePacket.durationMs * voicePacket.total,
pathLen: message.pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
}
final normalizedPathLen = _normalizedPathLen(message.pathLen);
final payloadBytes = _textFrameBaseBytes + message.text.length;
final hops = normalizedPathLen + 1;
final airtimeMs = _estimateLoRaAirtimeMs(
_meshPacketHeaderBytes + normalizedPathLen + payloadBytes,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
return Duration(
milliseconds: (airtimeMs * (1.0 + _defaultAirtimeBudgetFactor) * hops)
.round(),
);
}
int _normalizedPathLen(int pathLen) {
if (pathLen < 0 || pathLen >= 255) return 0;
return pathLen.clamp(0, 64).toInt();
}
double _estimateLoRaAirtimeMs(
int payloadLenBytes, {
int? radioBw,
int? radioSf,
int? radioCr,
}) {
final sf = _normalizeSf(radioSf);
final bw = _resolveBandwidthHz(radioBw).toDouble();
final cr = (_normalizeCr(radioCr) - 4).clamp(1, 4);
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
final symbolMs = ((1 << sf) / bw) * 1000.0;
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
final num =
(8 * payloadLenBytes) -
(4 * sf) +
28 +
(16 * _defaultLoRaCrcEnabled) -
(20 * ih);
final den = 4 * (sf - (2 * de));
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
final payloadSymbols =
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
final payloadMs = payloadSymbols * symbolMs;
return preambleMs + payloadMs;
}
int _normalizeSf(int? value) {
if (value == null) return _defaultLoRaSf;
if (value >= 5 && value <= 12) return value;
return _defaultLoRaSf;
}
int _normalizeCr(int? value) {
if (value == null) return _defaultLoRaCr;
if (value >= 5 && value <= 8) return value;
return _defaultLoRaCr;
}
int _resolveBandwidthHz(int? rawBw) {
if (rawBw == null) return _defaultLoRaBwHz;
if (rawBw > 1000) return rawBw;
switch (rawBw) {
case 0:
return 7800;
case 1:
return 10400;
case 2:
return 15600;
case 3:
return 20800;
case 4:
return 31250;
case 5:
return 41700;
case 6:
return 62500;
case 7:
return 125000;
case 8:
return 250000;
case 9:
return 500000;
default:
return _defaultLoRaBwHz;
}
}

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