mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
66 Commits
v2026.0307
...
v2026.0310
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9deb563c05 | ||
|
|
15ac3e2798 | ||
|
|
97addf7ba6 | ||
|
|
025bd737e7 | ||
|
|
07b675dec6 | ||
|
|
d675ce18dd | ||
|
|
a8e31bb2e8 | ||
|
|
3cd96ba961 | ||
|
|
a98fc6314e | ||
|
|
ae1ea71f58 | ||
|
|
4dff79cd7b | ||
|
|
cbcb329c70 | ||
|
|
7abf96abf2 | ||
|
|
13bf74178c | ||
|
|
66a00c9569 | ||
|
|
b60fa008eb | ||
|
|
d113f7a881 | ||
|
|
2ab8681ac4 | ||
|
|
de5fb1e164 | ||
|
|
dae7e67a8a | ||
|
|
d51924021f | ||
|
|
0f91292fd7 | ||
|
|
b471ab0990 | ||
|
|
771cddb80d | ||
|
|
bfd15065f0 | ||
|
|
f72db76538 | ||
|
|
a96f96ca4c | ||
|
|
9f62055eef | ||
|
|
6b2f18130c | ||
|
|
922b40b755 | ||
|
|
17d7c43745 | ||
|
|
e026c1dbbd | ||
|
|
9ff24c1fcb | ||
|
|
948a22c4d2 | ||
|
|
05153c9ceb | ||
|
|
cd508d5f74 | ||
|
|
937f91e496 | ||
|
|
1f826ae4c2 | ||
|
|
2e15ccb3f3 | ||
|
|
aa0777d4a3 | ||
|
|
e904d53d8b | ||
|
|
c4aef05dcf | ||
|
|
1c3af4f927 | ||
|
|
fe2e08c4e4 | ||
|
|
3433eae3a6 | ||
|
|
79c8664d04 | ||
|
|
162376cc47 | ||
|
|
adb8fe32f4 | ||
|
|
5966ddfbc1 | ||
|
|
1867f815d2 | ||
|
|
4010680da0 | ||
|
|
6bfc08f3ae | ||
|
|
fa37a20fc7 | ||
|
|
09f61740d2 | ||
|
|
97c1f3b27d | ||
|
|
cf70aa2bfb | ||
|
|
40e4db7b7f | ||
|
|
2c2e7b392a | ||
|
|
fd7d6afb70 | ||
|
|
3307a93640 | ||
|
|
8a4b29b968 | ||
|
|
0e4f727e26 | ||
|
|
4e76898c8d | ||
|
|
7214c73aea | ||
|
|
beb960cf62 | ||
|
|
01afcd2ea7 |
4
.github/workflows/build-artifacts.yml
vendored
4
.github/workflows/build-artifacts.yml
vendored
@@ -12,7 +12,7 @@ permissions:
|
|||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: "3.35.6"
|
FLUTTER_VERSION: "3.41.4"
|
||||||
APP_NAME: meshcore-sar
|
APP_NAME: meshcore-sar
|
||||||
ANDROID_NDK_VERSION: "27.0.12077973"
|
ANDROID_NDK_VERSION: "27.0.12077973"
|
||||||
|
|
||||||
@@ -280,7 +280,7 @@ jobs:
|
|||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
- name: Build web release
|
- name: Build web release
|
||||||
run: flutter build web --release --base-href /meshcore_sar_app/
|
run: flutter build web --release --base-href /meshcore-sar/
|
||||||
|
|
||||||
- name: Upload pages artifact
|
- name: Upload pages artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
|||||||
2
.github/workflows/build-multiplatform.yml
vendored
2
.github/workflows/build-multiplatform.yml
vendored
@@ -10,7 +10,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: '3.35.6'
|
FLUTTER_VERSION: '3.41.4'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
||||||
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
||||||
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp
|
`iOS TestFlight:` https://testflight.apple.com/join/nCZYMPPz
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
## 1. Overview
|
## 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):**
|
- **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):**
|
- **Control plane (raw binary request):**
|
||||||
- Binary image fetch request (same raw route as image fragments).
|
- Binary image fetch request (same raw route as image fragments).
|
||||||
- **Data plane (raw binary packets):**
|
- **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;
|
Images are never broadcast in full to channels. Chat carries only metadata;
|
||||||
pixels are fetched on demand when the user taps the image bubble.
|
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
|
## 2. Key Modules
|
||||||
|
|
||||||
- `lib/utils/image_message_parser.dart`
|
- `lib/utils/image_message_parser.dart`
|
||||||
- `ImagePacket` (binary fragment format)
|
- `ImagePacket` (binary fragment format)
|
||||||
- `ImageEnvelope` (`IE2`)
|
- `ImageEnvelope` (`IE4`)
|
||||||
- `ImageFetchRequest` (binary)
|
- `ImageFetchRequest` (binary)
|
||||||
- `fragmentImage()` — split compressed bytes into packets
|
- `fragmentImage()` — split compressed bytes into packets
|
||||||
- `reassembleImage()` — join received fragments into bytes
|
- `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`
|
- `lib/providers/image_provider.dart`
|
||||||
- Reassembly sessions, outgoing cache, deferred serving
|
- Reassembly sessions, outgoing cache, deferred serving
|
||||||
- Outgoing sessions also registered as complete incoming sessions for immediate local display
|
- 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`
|
- `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`
|
- `lib/widgets/messages/image_message_bubble.dart`
|
||||||
- Square cover thumbnail (up to 256 px); tap-to-load for received images;
|
- Square cover thumbnail (up to 256 px); tap-to-load for received images;
|
||||||
progress ring during fetch; full-screen `InteractiveViewer` on tap
|
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. 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:
|
Fields:
|
||||||
|
|
||||||
@@ -54,51 +59,50 @@ Fields:
|
|||||||
| `w` | base36 | Actual image width after compression (pixels) |
|
| `w` | base36 | Actual image width after compression (pixels) |
|
||||||
| `h` | base36 | Actual image height after compression (pixels) |
|
| `h` | base36 | Actual image height after compression (pixels) |
|
||||||
| `bytes` | base36 | Total compressed size in bytes |
|
| `bytes` | base36 | Total compressed size in bytes |
|
||||||
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
|
|
||||||
| `ts` | base36 | Unix timestamp (seconds) |
|
|
||||||
|
|
||||||
Compact format:
|
Compact format:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
|
IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example (256×171 landscape image, 14 fragments):
|
Example (256×171 landscape image, 14 fragments):
|
||||||
|
|
||||||
```text
|
```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.
|
Note: `sid` is base36 on wire and expands to 8-hex internally.
|
||||||
`w` and `h` reflect the actual post-compression dimensions, which preserve
|
`w` and `h` reflect the actual post-compression dimensions, which preserve
|
||||||
the source aspect ratio (contain within the configured max size).
|
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:
|
Binary payload format:
|
||||||
|
|
||||||
```text
|
```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 |
|
| Field | Value |
|
||||||
|------------------|--------------------------|
|
|------------------|--------------------------|
|
||||||
| `flags` | bit0=1 => request missing indices, else all |
|
| `flags` | bit0=1 => request missing indices, else all |
|
||||||
| `requesterKey6` | 6-byte requester key prefix |
|
| `requesterKey6` | 6-byte requester key prefix |
|
||||||
| `ts` | unix timestamp seconds (u32) |
|
|
||||||
|
|
||||||
### 3.3 Raw Image Packet (data plane)
|
### 3.3 Raw Image Packet (data plane)
|
||||||
|
|
||||||
Binary payload structure:
|
Binary payload structure:
|
||||||
|
|
||||||
- Byte 0: magic `0x49` (`'I'`)
|
- Byte 0: magic `0x49` (`'I'`)
|
||||||
- Bytes 1..4: session ID (4 bytes)
|
- Bytes 1..4: session ID (4 bytes)
|
||||||
- Byte 5: format ID
|
- Byte 5: fragment index (0-based)
|
||||||
- Byte 6: fragment index (0-based)
|
- Bytes 6..N: image data (max 152 bytes per fragment)
|
||||||
- Byte 7: total fragments
|
|
||||||
- Bytes 8..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
|
## 4. Compression Pipeline
|
||||||
|
|
||||||
@@ -152,11 +156,11 @@ only the shorter axis is padded — no cropping occurs.
|
|||||||
7. Envelope sent via normal message path:
|
7. Envelope sent via normal message path:
|
||||||
- Channel: `sendChannelMessage`
|
- Channel: `sendChannelMessage`
|
||||||
- Direct: `sendTextMessage`
|
- 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. Incoming Flow (Receive)
|
||||||
|
|
||||||
### 6.1 `IE2` envelope received
|
### 6.1 `IE4` envelope received
|
||||||
|
|
||||||
`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to
|
`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to
|
||||||
chat. The bubble shows a grey square placeholder with a download icon.
|
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):
|
`AppProvider` treats it as control-plane only (not added to chat):
|
||||||
|
|
||||||
- Validates requester key prefix.
|
|
||||||
- Resolves requester contact.
|
- Resolves requester contact.
|
||||||
- Calls `imageProvider.serveSessionTo()` which streams all cached fragments.
|
- 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
|
`AppProvider.onRawDataReceived` parses `ImagePacket` binary and calls
|
||||||
`imageProvider.addFragment()`. When the session becomes complete, the bubble
|
`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
|
`_sessions[sessionId]`, so the sender sees the image immediately in the bubble
|
||||||
(no tap-to-load required for own messages).
|
(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
|
## 8. Display
|
||||||
|
|
||||||
`ImageMessageBubble` (max width 256 px):
|
`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)`
|
- **Complete session**: `AspectRatio(1.0)` → `AvifImage.memory(fit: cover)`
|
||||||
square thumbnail; tap → full-screen `InteractiveViewer` with fade transition.
|
square thumbnail; tap → full-screen `InteractiveViewer` with fade transition.
|
||||||
- **Incomplete/missing**: grey square placeholder with download icon;
|
- **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.
|
- **Loading**: circular progress indicator showing `received/total` count.
|
||||||
- **Error**: broken-image icon.
|
- **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:
|
The estimate is airtime-based (LoRa packet model), not just compressed image size:
|
||||||
|
|
||||||
- Source inputs:
|
- Source inputs:
|
||||||
- `total` fragments and `bytes` from `IE2` envelope
|
- `total` fragments and `bytes` from `IE4` envelope
|
||||||
- all numeric envelope values are decoded from base36
|
- all numeric envelope values are decoded from base36
|
||||||
- `pathLen` from message metadata
|
- `pathLen` from message metadata
|
||||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||||
- Per-fragment payload model:
|
- Per-fragment payload model:
|
||||||
- `meshHeader(2)` + `pathLen` + `imageHeader(8)` + `fragmentBytes`
|
- `meshHeader(2)` + `pathLen` + `imageHeader(6)` + `fragmentBytes`
|
||||||
- LoRa airtime:
|
- LoRa airtime:
|
||||||
- standard symbol-time formula (preamble + payload symbols)
|
- standard symbol-time formula (preamble + payload symbols)
|
||||||
- Mesh pacing/hops:
|
- Mesh pacing/hops:
|
||||||
@@ -246,9 +264,12 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
|
|||||||
## 11. Operational Constraints
|
## 11. Operational Constraints
|
||||||
|
|
||||||
- No firmware changes required (reuses `cmdSendRawData` / `pushRawData`).
|
- 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.
|
- Raw return path requires a valid direct route to requester.
|
||||||
- Available on iOS and Android (`image_picker` + `flutter_avif`).
|
- 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
|
### 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;
|
- only nodes on that path relay it;
|
||||||
- it is **not** received by everyone in the mesh.
|
- 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
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -272,8 +314,8 @@ sequenceDiagram
|
|||||||
A->>A: Compress: contain resize → grayscale → PNG → AVIF
|
A->>A: Compress: contain resize → grayscale → PNG → AVIF
|
||||||
A->>A: Fragment into ≤152B packets
|
A->>A: Fragment into ≤152B packets
|
||||||
A->>A: Cache outgoing + populate local session (immediate display)
|
A->>A: Cache outgoing + populate local session (immediate display)
|
||||||
A->>M: Send IE2 envelope (actual w×h, fragment count)
|
A->>M: Send IE4 envelope (actual w×h, fragment count)
|
||||||
M->>B: Deliver IE2
|
M->>B: Deliver IE4
|
||||||
B->>B: Render grey placeholder bubble
|
B->>B: Render grey placeholder bubble
|
||||||
B->>A: Tap → send binary fetch request
|
B->>A: Tap → send binary fetch request
|
||||||
A->>B: Stream binary ImagePackets
|
A->>B: Stream binary ImagePackets
|
||||||
|
|||||||
148
docs/swarm-mode-technical.md
Normal file
148
docs/swarm-mode-technical.md
Normal 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.
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
## 1. Overview
|
## 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):**
|
- **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):**
|
- **Control plane (raw binary request):**
|
||||||
- Binary voice fetch request (same raw route as voice packets).
|
- Binary voice fetch request (same raw route as voice packets).
|
||||||
- **Data plane (raw binary 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.
|
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
|
## 2. Key Modules
|
||||||
|
|
||||||
- `lib/utils/voice_message_parser.dart`
|
- `lib/utils/voice_message_parser.dart`
|
||||||
- `VoicePacket` (legacy text + binary packet format)
|
- `VoicePacket` (binary direct-packet format)
|
||||||
- `VoiceEnvelope` (`VE2`)
|
- `VoiceEnvelope` (`VE3`)
|
||||||
- `VoiceFetchRequest` (binary)
|
- `VoiceFetchRequest` (binary)
|
||||||
- `lib/screens/messages_tab.dart`
|
- `lib/screens/messages_tab.dart`
|
||||||
- Capture/encode voice, cache encoded packets, send envelope only
|
- 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
|
- Reassembly/playback sessions
|
||||||
- Outgoing session cache + deferred serving
|
- Outgoing session cache + deferred serving
|
||||||
- `lib/providers/app_provider.dart`
|
- `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
|
- Handles raw packet ingestion
|
||||||
- `lib/widgets/messages/voice_message_bubble.dart`
|
- `lib/widgets/messages/voice_message_bubble.dart`
|
||||||
- Play behavior (immediate play if complete, otherwise fetch + auto-play)
|
- Play behavior (immediate play if complete, otherwise fetch + auto-play)
|
||||||
- `lib/providers/messages_provider.dart`
|
- `lib/providers/messages_provider.dart`
|
||||||
- Message-level voice detection (`VE2` + legacy `V:`)
|
- Message-level voice detection (`VE3`)
|
||||||
- `lib/services/message_storage_service.dart`
|
- `lib/services/message_storage_service.dart`
|
||||||
- Persists `isVoice` and `voiceId`
|
- Persists `isVoice` and `voiceId`
|
||||||
|
|
||||||
## 3. Wire Formats
|
## 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:
|
Fields:
|
||||||
|
|
||||||
@@ -46,21 +49,19 @@ Fields:
|
|||||||
- `mode` (base36): codec mode ID (`VoicePacketMode.id`)
|
- `mode` (base36): codec mode ID (`VoicePacketMode.id`)
|
||||||
- `total` (base36): packet count (1..255)
|
- `total` (base36): packet count (1..255)
|
||||||
- `durS` (base36): estimated duration in seconds
|
- `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.
|
`sid` is base36 on wire and expands to 8-hex internally.
|
||||||
|
|
||||||
Compact format:
|
Compact format:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
|
VE3:{sid}:{mode}:{total}:{durS}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
VE2:a:1:4:4:aabbccddeeff:s44we8
|
VE3:a:1:4:4
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 Voice Fetch Request (binary)
|
### 3.2 Voice Fetch Request (binary)
|
||||||
@@ -68,7 +69,7 @@ VE2:a:1:4:4:aabbccddeeff:s44we8
|
|||||||
Binary payload format:
|
Binary payload format:
|
||||||
|
|
||||||
```text
|
```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)
|
### 3.3 Raw Voice Packet (data plane)
|
||||||
@@ -77,10 +78,10 @@ Binary payload structure:
|
|||||||
|
|
||||||
- Byte 0: magic `0x56` (`'V'`)
|
- Byte 0: magic `0x56` (`'V'`)
|
||||||
- Bytes 1..4: session ID (4 bytes)
|
- Bytes 1..4: session ID (4 bytes)
|
||||||
- Byte 5: mode ID
|
- Byte 5: packet index
|
||||||
- Byte 6: packet index
|
- Bytes 6..N: codec2 data
|
||||||
- Byte 7: total packets
|
|
||||||
- Bytes 8..N: codec2 data
|
Header is 6 bytes. Mode and total packet count come from the `VE3` envelope.
|
||||||
|
|
||||||
## 4. Outgoing Flow (Send)
|
## 4. Outgoing Flow (Send)
|
||||||
|
|
||||||
@@ -88,27 +89,38 @@ Binary payload structure:
|
|||||||
2. Each chunk is codec2-encoded into `VoicePacket` objects.
|
2. Each chunk is codec2-encoded into `VoicePacket` objects.
|
||||||
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
|
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
|
||||||
4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`).
|
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`
|
- channel/room: `sendChannelMessage`
|
||||||
- direct: `sendTextMessage`
|
- direct: `sendTextMessage`
|
||||||
6. **No raw audio packets are sent during initial send.**
|
6. **No raw audio packets are sent during initial send.**
|
||||||
|
|
||||||
## 5. Incoming Routing
|
## 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
|
### 5.2 Binary voice fetch request received
|
||||||
|
|
||||||
`AppProvider` treats it as control-plane only:
|
`AppProvider` treats it as control-plane only:
|
||||||
|
|
||||||
- request is not added to chat
|
- request is not added to chat
|
||||||
- validates requester prefix match against sender metadata
|
|
||||||
- resolves requester contact via key prefix
|
- resolves requester contact via key prefix
|
||||||
- calls `voiceProvider.serveSessionTo(...)`
|
- 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`.
|
`AppProvider.onRawDataReceived` parses `VoicePacket` binary and appends to session in `VoiceProvider`.
|
||||||
|
|
||||||
@@ -118,10 +130,15 @@ In `VoiceMessageBubble`:
|
|||||||
|
|
||||||
- If session already complete: play immediately.
|
- If session already complete: play immediately.
|
||||||
- If incomplete/missing:
|
- If incomplete/missing:
|
||||||
1. Resolve sender contact (message sender prefix or `VE2.senderKey6` fallback)
|
1. Resolve sender contact from message sender metadata
|
||||||
2. Send direct binary fetch request
|
2. Prefer a direct fetch from the original sender if its raw route is healthy
|
||||||
3. Show requesting state in UI
|
3. If the sender path does not respond, fan out a raw swarm request with the
|
||||||
4. Auto-play when session becomes complete
|
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"**.
|
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:
|
Serving prerequisites:
|
||||||
|
|
||||||
- session exists in cache
|
- session exists in outgoing cache or already-received session state
|
||||||
- `sendRawPacketCallback` configured
|
- `sendRawPacketCallback` configured
|
||||||
- requester has direct path (`outPathLen >= 0`)
|
- requester has direct path (`outPathLen >= 0`)
|
||||||
|
|
||||||
|
Received partial sessions can therefore act as relay sources during swarm
|
||||||
|
recovery.
|
||||||
|
|
||||||
## 8. Persistence
|
## 8. Persistence
|
||||||
|
|
||||||
`MessageStorageService` now stores and restores:
|
`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:
|
The estimate is airtime-based (LoRa packet model), not file-duration-only:
|
||||||
|
|
||||||
- Source inputs:
|
- Source inputs:
|
||||||
- `packetCount` and `durationMs` from `VE2` envelope, or
|
- `packetCount` and `durationMs` from `VE3` envelope, or
|
||||||
- numeric envelope values decoded from base36
|
- numeric envelope values decoded from base36
|
||||||
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
|
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
|
||||||
- `pathLen` from message metadata
|
- `pathLen` from message metadata
|
||||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||||
- Per-packet payload model:
|
- Per-packet payload model:
|
||||||
- `meshHeader(2)` + `pathLen` + `voiceHeader(8)` + `codec2Bytes`
|
- `meshHeader(2)` + `pathLen` + `voiceHeader(6)` + `codec2Bytes`
|
||||||
- LoRa airtime:
|
- LoRa airtime:
|
||||||
- standard symbol-time formula (preamble + payload symbols)
|
- standard symbol-time formula (preamble + payload symbols)
|
||||||
- Mesh pacing/hops:
|
- Mesh pacing/hops:
|
||||||
@@ -192,9 +212,12 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
|
|||||||
## 11. Operational Constraints
|
## 11. Operational Constraints
|
||||||
|
|
||||||
- No firmware changes required.
|
- 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.
|
- Raw return path needs a currently valid direct route to requester.
|
||||||
- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`).
|
- 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
|
### 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;
|
- only nodes on that path relay it;
|
||||||
- it is **not** received by everyone in the mesh.
|
- it is **not** received by everyone in the mesh.
|
||||||
|
|
||||||
## 12. Backward Compatibility
|
## 12. High-Level Sequence
|
||||||
|
|
||||||
- Legacy `V:` text packet parsing is still supported.
|
|
||||||
- Message voice detection accepts `VE2` and legacy `V:` formats.
|
|
||||||
|
|
||||||
## 13. High-Level Sequence
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant A as Sender App
|
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
|
participant B as Receiver App
|
||||||
|
|
||||||
A->>A: Record + encode voice packets
|
A->>A: Record + encode voice packets
|
||||||
A->>A: Cache session packets (TTL 15m)
|
A->>A: Cache session packets (TTL 15m)
|
||||||
A->>M: Send VE2 envelope
|
A->>M: Send VE3 envelope
|
||||||
M->>B: Deliver VE2
|
M->>B: Deliver VE3
|
||||||
B->>B: Render voice bubble (metadata only)
|
B->>B: Render voice bubble (metadata only)
|
||||||
B->>A: Send binary fetch request on Play
|
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: Reassemble session
|
||||||
B->>B: Auto-play when complete
|
B->>B: Auto-play when complete
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -59,10 +59,6 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- nsd_ios (0.0.1):
|
- nsd_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- ObjectBox (4.4.1)
|
|
||||||
- objectbox_flutter_libs (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
- ObjectBox (= 4.4.1)
|
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider_foundation (0.0.1):
|
- path_provider_foundation (0.0.1):
|
||||||
@@ -103,7 +99,6 @@ DEPENDENCIES:
|
|||||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- nsd_ios (from `.symlinks/plugins/nsd_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`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
@@ -118,7 +113,6 @@ SPEC REPOS:
|
|||||||
trunk:
|
trunk:
|
||||||
- DKImagePickerController
|
- DKImagePickerController
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
- ObjectBox
|
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SwiftyGif
|
- SwiftyGif
|
||||||
|
|
||||||
@@ -149,8 +143,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
nsd_ios:
|
nsd_ios:
|
||||||
:path: ".symlinks/plugins/nsd_ios/ios"
|
:path: ".symlinks/plugins/nsd_ios/ios"
|
||||||
objectbox_flutter_libs:
|
|
||||||
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
@@ -171,7 +163,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/vibration/ios"
|
:path: ".symlinks/plugins/vibration/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd
|
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
||||||
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
|
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
|
||||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||||
@@ -182,12 +174,10 @@ SPEC CHECKSUMS:
|
|||||||
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
||||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||||
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
||||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
|
||||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
||||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
|
||||||
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
|
|||||||
@@ -489,7 +489,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 95;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -511,7 +511,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 95;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -530,7 +530,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 95;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -547,7 +547,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 95;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
@@ -679,7 +679,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 95;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@@ -702,7 +702,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 95;
|
CURRENT_PROJECT_VERSION = 107;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -8,6 +9,7 @@ import Flutter
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,11 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>95</string>
|
<string>107</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations</string>
|
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
|
|||||||
@@ -5,22 +5,24 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000239">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000243">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.427601">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.511584">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="110.117264">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="95.467725">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="269.473315">
|
<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 'Fastlane::Actions.execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:255:in 'block in Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:157:in 'Fastlane::Runner#trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/fast_file.rb:159:in 'Fastlane::FastFile#method_missing' Fastfile:23:in 'block (2 levels) in Fastlane::FastFile#parsing_binding' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane.rb:41:in 'Fastlane::Lane#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:49:in 'block in Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane_manager.rb:46:in 'Fastlane::LaneManager.cruise_lane' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/command_line_handler.rb:34:in 'Fastlane::CommandLineHandler.handle' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:110:in 'block (2 levels) in Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in 'Commander::Command#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in 'Commander::Command#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in 'Commander::Runner#run_active_command' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in 'Commander::Runner#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in 'Commander::Delegates#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:363:in 'Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:43:in 'Fastlane::CommandsGenerator.start' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/cli_tools_distributor.rb:132:in 'Fastlane::CLIToolsDistributor.take_off' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/bin/fastlane:23:in '<top (required)>' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Kernel#load' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Gem.activate_and_load_bin_path' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/bin/fastlane:25:in '<main>' Error uploading ipa file: [Application Loader Error Output]: [ContentDelivery.Uploader.104B12FA0]
|
||||||
[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 aren’t permitted. (ID: 678497bb-0ad4-4cd7-b636-f9aef1caa269)
|
[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 aren’t permitted. (ID: 678497bb-0ad4-4cd7-b636-f9aef1caa269)
|
||||||
" />
|
" />
|
||||||
|
|
||||||
|
|||||||
1398
lib/l10n/app_de.arb
1398
lib/l10n/app_de.arb
File diff suppressed because it is too large
Load Diff
2619
lib/l10n/app_el.arb
2619
lib/l10n/app_el.arb
File diff suppressed because it is too large
Load Diff
1728
lib/l10n/app_en.arb
1728
lib/l10n/app_en.arb
File diff suppressed because it is too large
Load Diff
1387
lib/l10n/app_es.arb
1387
lib/l10n/app_es.arb
File diff suppressed because it is too large
Load Diff
1392
lib/l10n/app_fr.arb
1392
lib/l10n/app_fr.arb
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1392
lib/l10n/app_it.arb
1392
lib/l10n/app_it.arb
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -29,9 +29,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get disconnect => 'Trennen';
|
String get disconnect => 'Trennen';
|
||||||
|
|
||||||
@override
|
|
||||||
String get scanningForDevices => 'Suche nach Geräten...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noDevicesFound => 'Keine Geräte gefunden';
|
String get noDevicesFound => 'Keine Geräte gefunden';
|
||||||
|
|
||||||
@@ -63,27 +60,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get failedToGetGpsLocation =>
|
String get failedToGetGpsLocation =>
|
||||||
'GPS-Position konnte nicht abgerufen werden';
|
'GPS-Position konnte nicht abgerufen werden';
|
||||||
|
|
||||||
@override
|
|
||||||
String advertisedAtLocation(String latitude, String longitude) {
|
|
||||||
return 'Position gesendet bei $latitude, $longitude';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToAdvertise(String error) {
|
String failedToAdvertise(String error) {
|
||||||
return 'Senden fehlgeschlagen: $error';
|
return 'Senden fehlgeschlagen: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String reconnecting(int attempt, int max) {
|
|
||||||
return 'Wiederverbindung... ($attempt/$max)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cancelReconnection => 'Wiederverbindung abbrechen';
|
String get cancelReconnection => 'Wiederverbindung abbrechen';
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapManagement => 'Kartenverwaltung';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get general => 'Allgemein';
|
String get general => 'Allgemein';
|
||||||
|
|
||||||
@@ -130,13 +114,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get displayPacketActivity =>
|
String get displayPacketActivity =>
|
||||||
'Paketaktivitätsindikatoren in der oberen Leiste anzeigen';
|
'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
|
@override
|
||||||
String get disableMap => 'Karte deaktivieren';
|
String get disableMap => 'Karte deaktivieren';
|
||||||
|
|
||||||
@@ -150,74 +127,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get chooseLanguage => 'Sprache auswählen';
|
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
|
@override
|
||||||
String get save => 'Speichern';
|
String get save => 'Speichern';
|
||||||
|
|
||||||
@@ -253,12 +162,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get moreInfo => 'Mehr Info';
|
String get moreInfo => 'Mehr Info';
|
||||||
|
|
||||||
@override
|
|
||||||
String get learnMoreAbout => 'Erfahren Sie mehr über MeshCore SAR';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get developer => 'Entwickler';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get packageName => 'Paketname';
|
String get packageName => 'Paketname';
|
||||||
|
|
||||||
@@ -335,9 +238,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get refresh => 'Aktualisieren';
|
String get refresh => 'Aktualisieren';
|
||||||
|
|
||||||
@override
|
|
||||||
String get sendDirectMessage => 'Senden';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get resetPath => 'Pfad zurücksetzen (Umleitung)';
|
String get resetPath => 'Pfad zurücksetzen (Umleitung)';
|
||||||
|
|
||||||
@@ -457,21 +357,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return '$count Zeichnung$plural teilen';
|
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
|
@override
|
||||||
String get showReceivedDrawings => 'Empfangene Zeichnungen anzeigen';
|
String get showReceivedDrawings => 'Empfangene Zeichnungen anzeigen';
|
||||||
|
|
||||||
@@ -493,9 +378,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearAll => 'Alle löschen';
|
String get clearAll => 'Alle löschen';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noLocalDrawings => 'Keine lokalen Zeichnungen zum Teilen';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicChannel => 'Öffentlicher Kanal';
|
String get publicChannel => 'Öffentlicher Kanal';
|
||||||
|
|
||||||
@@ -505,37 +387,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storedPermanently => 'Dauerhaft im Raum gespeichert';
|
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
|
@override
|
||||||
String get notConnectedToDevice => 'Nicht mit Gerät verbunden';
|
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
|
@override
|
||||||
String get typeYourMessage => 'Geben Sie Ihre Nachricht ein...';
|
String get typeYourMessage => 'Geben Sie Ihre Nachricht ein...';
|
||||||
|
|
||||||
@@ -565,9 +419,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get location => 'Standort';
|
String get location => 'Standort';
|
||||||
|
|
||||||
@override
|
|
||||||
String get myLocation => 'Mein Standort';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fromMap => 'Von Karte';
|
String get fromMap => 'Von Karte';
|
||||||
|
|
||||||
@@ -630,9 +481,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get flood => 'Flut';
|
String get flood => 'Flut';
|
||||||
|
|
||||||
@override
|
|
||||||
String get admin => 'Admin';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loggedIn => 'Angemeldet';
|
String get loggedIn => 'Angemeldet';
|
||||||
|
|
||||||
@@ -642,29 +490,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get distance => 'Entfernung';
|
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
|
@override
|
||||||
String directPingTimeout(String name) {
|
String directPingTimeout(String name) {
|
||||||
return 'Direkter Ping-Timeout - wiederhole $name mit Flutung...';
|
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
|
@override
|
||||||
String pingFailed(String name) {
|
String pingFailed(String name) {
|
||||||
return 'Ping fehlgeschlagen an $name - keine Antwort erhalten';
|
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.';
|
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
|
@override
|
||||||
String failedToRemoveContact(String error) {
|
String failedToRemoveContact(String error) {
|
||||||
return 'Fehler beim Entfernen des Kontakts: $error';
|
return 'Fehler beim Entfernen des Kontakts: $error';
|
||||||
@@ -729,11 +549,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get telemetry => 'Telemetrie';
|
String get telemetry => 'Telemetrie';
|
||||||
|
|
||||||
@override
|
|
||||||
String requestingTelemetry(String name) {
|
|
||||||
return 'Fordere Telemetrie von $name an...';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get voltage => 'Spannung';
|
String get voltage => 'Spannung';
|
||||||
|
|
||||||
@@ -805,9 +620,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get showAll => 'Alle anzeigen';
|
String get showAll => 'Alle anzeigen';
|
||||||
|
|
||||||
@override
|
|
||||||
String get nearbyContacts => 'Kontakte in der Nähe';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationUnavailable => 'Standort nicht verfügbar';
|
String get locationUnavailable => 'Standort nicht verfügbar';
|
||||||
|
|
||||||
@@ -826,7 +638,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String latLonFormat(String latitude, String longitude) {
|
String latLonFormat(String latitude, String longitude) {
|
||||||
return 'Lat: $latitude Lon: $longitude';
|
return 'Breite: $latitude Lange: $longitude';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -848,175 +660,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get channels => 'Kanäle';
|
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
|
@override
|
||||||
String get selectMapLayer => 'Kartenebene auswählen';
|
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
|
@override
|
||||||
String get openStreetMap => 'OpenStreetMap';
|
String get openStreetMap => 'OpenStreetMap';
|
||||||
|
|
||||||
@@ -1035,12 +681,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get googleTerrain => 'Google Gelände';
|
String get googleTerrain => 'Google Gelände';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadVisibleArea => 'Sichtbaren Bereich herunterladen';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get initializingMap => 'Initialisiere Karte...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dragToPosition => 'Zur Position ziehen';
|
String get dragToPosition => 'Zur Position ziehen';
|
||||||
|
|
||||||
@@ -1056,53 +696,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarAlert => 'SAR-ALARM';
|
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
|
@override
|
||||||
String get textCopiedToClipboard => 'Text in Zwischenablage kopiert';
|
String get textCopiedToClipboard => 'Text in Zwischenablage kopiert';
|
||||||
|
|
||||||
@@ -1114,9 +707,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get cannotReplyContactNotFound =>
|
String get cannotReplyContactNotFound =>
|
||||||
'Antwort nicht möglich: Kontakt nicht gefunden';
|
'Antwort nicht möglich: Kontakt nicht gefunden';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messageDeleted => 'Nachricht gelöscht';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get copyText => 'Text kopieren';
|
String get copyText => 'Text kopieren';
|
||||||
|
|
||||||
@@ -1153,12 +743,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarLocationShare => 'SAR Standort';
|
String get sarLocationShare => 'SAR Standort';
|
||||||
|
|
||||||
@override
|
|
||||||
String get locationShared => 'Standort geteilt';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get refreshedContacts => 'Kontakte aktualisiert';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get justNow => 'Gerade eben';
|
String get justNow => 'Gerade eben';
|
||||||
|
|
||||||
@@ -1200,7 +784,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get failed => 'Fehlgeschlagen';
|
String get failed => 'Fehlgeschlagen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get broadcast => 'Broadcast';
|
String get broadcast => 'Rundspruch';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String deliveredToContacts(int delivered, int total) {
|
String deliveredToContacts(int delivered, int total) {
|
||||||
@@ -1260,130 +844,28 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String maxPowerDbm(int power) {
|
String maxPowerDbm(int power) {
|
||||||
return 'Max: $power dBm';
|
return 'Max.: $power dBm';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get you => 'Du';
|
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
|
@override
|
||||||
String exportFailed(String error) {
|
String exportFailed(String error) {
|
||||||
return 'Export failed: $error';
|
return 'Export fehlgeschlagen: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String importFailed(String error) {
|
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
|
@override
|
||||||
String get unknown => 'Unbekannt';
|
String get unknown => 'Unbekannt';
|
||||||
|
|
||||||
@override
|
|
||||||
String get bounds => 'Grenzen';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onlineLayers => 'Online-Ebenen';
|
String get onlineLayers => 'Online-Ebenen';
|
||||||
|
|
||||||
@override
|
|
||||||
String get offlineLayers => 'Offline-Ebenen';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationTrail => 'Standortverlauf';
|
String get locationTrail => 'Standortverlauf';
|
||||||
|
|
||||||
@@ -1423,47 +905,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trailControls => 'Verlaufssteuerung';
|
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
|
@override
|
||||||
String get contactTrails => 'Kontaktverläufe';
|
String get contactTrails => 'Kontaktverläufe';
|
||||||
|
|
||||||
@@ -1526,10 +967,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get telemetryAndLocationSharing => 'Telemetrie & Standortfreigabe';
|
String get telemetryAndLocationSharing => 'Telemetrie & Standortfreigabe';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lat => 'Lat';
|
String get lat => 'Breite';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get lon => 'Lon';
|
String get lon => 'Lange';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get useCurrentLocation => 'Aktuellen Standort verwenden';
|
String get useCurrentLocation => 'Aktuellen Standort verwenden';
|
||||||
@@ -1541,7 +982,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get chatNode => 'Chat-Knoten';
|
String get chatNode => 'Chat-Knoten';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get repeater => 'Repeater';
|
String get repeater => 'Relais';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get roomChannel => 'Raum/Kanal';
|
String get roomChannel => 'Raum/Kanal';
|
||||||
@@ -1585,7 +1026,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get templateName => 'Vorlagenname';
|
String get templateName => 'Vorlagenname';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateNameHint => 'e.g. Found Person';
|
String get templateNameHint => 'z. B. Gefundene Person';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateEmoji => 'Emoji';
|
String get templateEmoji => 'Emoji';
|
||||||
@@ -1597,16 +1038,16 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get nameRequired => 'Name ist erforderlich';
|
String get nameRequired => 'Name ist erforderlich';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDescription => 'Description (Optional)';
|
String get templateDescription => 'Beschreibung (optional)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDescriptionHint => 'Add additional context...';
|
String get templateDescriptionHint => 'Zusatzlichen Kontext hinzufugen...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateColor => 'Color';
|
String get templateColor => 'Farbe';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get previewFormat => 'Preview (SAR Message Format)';
|
String get previewFormat => 'Vorschau (SAR-Nachrichtenformat)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get importFromClipboard => 'Importieren';
|
String get importFromClipboard => 'Importieren';
|
||||||
@@ -1616,26 +1057,26 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String deleteTemplateConfirmation(String name) {
|
String deleteTemplateConfirmation(String name) {
|
||||||
return 'Delete template \'$name\'?';
|
return 'Vorlage \'$name\' loschen?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateAdded => 'Template added';
|
String get templateAdded => 'Vorlage hinzugefugt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateUpdated => 'Template updated';
|
String get templateUpdated => 'Vorlage aktualisiert';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDeleted => 'Template deleted';
|
String get templateDeleted => 'Vorlage geloscht';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String templatesImported(int count) {
|
String templatesImported(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'Imported $count templates',
|
other: '$count Vorlagen importiert',
|
||||||
one: 'Imported 1 template',
|
one: '1 Vorlage importiert',
|
||||||
zero: 'No templates imported',
|
zero: 'Keine Vorlagen importiert',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -1645,8 +1086,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'Exported $count templates to clipboard',
|
other: '$count Vorlagen in die Zwischenablage exportiert',
|
||||||
one: 'Exported 1 template to clipboard',
|
one: '1 Vorlage in die Zwischenablage exportiert',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -1665,10 +1106,11 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get resetComplete => 'Vorlagen auf Standard zurückgesetzt';
|
String get resetComplete => 'Vorlagen auf Standard zurückgesetzt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noTemplates => 'No templates available';
|
String get noTemplates => 'Keine Vorlagen verfugbar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tapAddToCreate => 'Tap + to create your first template';
|
String get tapAddToCreate =>
|
||||||
|
'Tippen Sie auf +, um Ihre erste Vorlage zu erstellen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ok => 'OK';
|
String get ok => 'OK';
|
||||||
@@ -1736,20 +1178,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noRoomsFound => 'Keine Räume gefunden';
|
String get noRoomsFound => 'Keine Räume gefunden';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noContactsOrRoomsAvailable =>
|
|
||||||
'Keine Kontakte oder Räume verfügbar';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noRecipientsAvailable => 'Keine Empfänger verfügbar';
|
String get noRecipientsAvailable => 'Keine Empfänger verfügbar';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noChannelsFound => 'Keine Kanäle gefunden';
|
String get noChannelsFound => 'Keine Kanäle gefunden';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messagesWillBeSentToPublicChannel =>
|
|
||||||
'Nachrichten werden an öffentlichen Kanal gesendet';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newMessage => 'Neue Nachricht';
|
String get newMessage => 'Neue Nachricht';
|
||||||
|
|
||||||
@@ -1932,10 +1366,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rectangleDrawing => 'Rechteck';
|
String get rectangleDrawing => 'Rechteck';
|
||||||
|
|
||||||
@override
|
|
||||||
String get coordinatesCopiedToClipboard =>
|
|
||||||
'Koordinaten in Zwischenablage kopiert';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get manualCoordinates => 'Manuelle Koordinaten';
|
String get manualCoordinates => 'Manuelle Koordinaten';
|
||||||
|
|
||||||
@@ -1948,31 +1378,9 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get longitudeLabel => 'Längengrad';
|
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
|
@override
|
||||||
String get exampleCoordinates => 'Beispiel: 46.0569, 14.5058';
|
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
|
@override
|
||||||
String get shareDrawing => 'Zeichnung teilen';
|
String get shareDrawing => 'Zeichnung teilen';
|
||||||
|
|
||||||
@@ -2028,12 +1436,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forestRoads => 'Waldwege';
|
String get forestRoads => 'Waldwege';
|
||||||
|
|
||||||
@override
|
|
||||||
String get showCadastralParcels => 'Katasterparzellen anzeigen';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get showForestRoads => 'Waldwege anzeigen';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get wmsOverlays => 'WMS Überlagerungen';
|
String get wmsOverlays => 'WMS Überlagerungen';
|
||||||
|
|
||||||
@@ -2138,10 +1540,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
return 'Kanal konnte nicht gelöscht werden: $error';
|
return 'Kanal konnte nicht gelöscht werden: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get allChannelSlotsInUse =>
|
|
||||||
'Alle Kanalplätze sind belegt (maximal 39 benutzerdefinierte Kanäle)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get createChannel => 'Kanal erstellen';
|
String get createChannel => 'Kanal erstellen';
|
||||||
|
|
||||||
@@ -2183,21 +1581,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get wizardConnectingFeature3 =>
|
String get wizardConnectingFeature3 =>
|
||||||
'Funktioniert vollständig offline - kein Internet erforderlich';
|
'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
|
@override
|
||||||
String get wizardChannelTitle => 'Kanäle';
|
String get wizardChannelTitle => 'Kanäle';
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,9 +29,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get disconnect => 'Disconnect';
|
String get disconnect => 'Disconnect';
|
||||||
|
|
||||||
@override
|
|
||||||
String get scanningForDevices => 'Scanning for devices...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noDevicesFound => 'No devices found';
|
String get noDevicesFound => 'No devices found';
|
||||||
|
|
||||||
@@ -62,27 +59,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get failedToGetGpsLocation => 'Failed to get GPS location';
|
String get failedToGetGpsLocation => 'Failed to get GPS location';
|
||||||
|
|
||||||
@override
|
|
||||||
String advertisedAtLocation(String latitude, String longitude) {
|
|
||||||
return 'Advertised at $latitude, $longitude';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToAdvertise(String error) {
|
String failedToAdvertise(String error) {
|
||||||
return 'Failed to advertise: $error';
|
return 'Failed to advertise: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String reconnecting(int attempt, int max) {
|
|
||||||
return 'Reconnecting... ($attempt/$max)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cancelReconnection => 'Cancel reconnection';
|
String get cancelReconnection => 'Cancel reconnection';
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapManagement => 'Map Management';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get general => 'General';
|
String get general => 'General';
|
||||||
|
|
||||||
@@ -129,13 +113,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get displayPacketActivity =>
|
String get displayPacketActivity =>
|
||||||
'Display packet activity indicators in top bar';
|
'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
|
@override
|
||||||
String get disableMap => 'Disable Map';
|
String get disableMap => 'Disable Map';
|
||||||
|
|
||||||
@@ -149,74 +126,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get chooseLanguage => 'Choose Language';
|
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
|
@override
|
||||||
String get save => 'Save';
|
String get save => 'Save';
|
||||||
|
|
||||||
@@ -252,12 +161,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get moreInfo => 'More Info';
|
String get moreInfo => 'More Info';
|
||||||
|
|
||||||
@override
|
|
||||||
String get learnMoreAbout => 'Learn more about MeshCore SAR';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get developer => 'Developer';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get packageName => 'Package Name';
|
String get packageName => 'Package Name';
|
||||||
|
|
||||||
@@ -333,9 +236,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get refresh => 'Refresh';
|
String get refresh => 'Refresh';
|
||||||
|
|
||||||
@override
|
|
||||||
String get sendDirectMessage => 'Send';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get resetPath => 'Reset Path (Re-route)';
|
String get resetPath => 'Reset Path (Re-route)';
|
||||||
|
|
||||||
@@ -455,21 +355,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Share $count Drawing$plural';
|
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
|
@override
|
||||||
String get showReceivedDrawings => 'Show Received Drawings';
|
String get showReceivedDrawings => 'Show Received Drawings';
|
||||||
|
|
||||||
@@ -491,9 +376,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearAll => 'Clear All';
|
String get clearAll => 'Clear All';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noLocalDrawings => 'No local drawings to share';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicChannel => 'Public Channel';
|
String get publicChannel => 'Public Channel';
|
||||||
|
|
||||||
@@ -503,37 +385,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storedPermanently => 'Stored permanently in room';
|
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
|
@override
|
||||||
String get notConnectedToDevice => 'Not connected to device';
|
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
|
@override
|
||||||
String get typeYourMessage => 'Type your message...';
|
String get typeYourMessage => 'Type your message...';
|
||||||
|
|
||||||
@@ -563,9 +417,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get location => 'Location';
|
String get location => 'Location';
|
||||||
|
|
||||||
@override
|
|
||||||
String get myLocation => 'My Location';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fromMap => 'From Map';
|
String get fromMap => 'From Map';
|
||||||
|
|
||||||
@@ -627,9 +478,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get flood => 'Flood';
|
String get flood => 'Flood';
|
||||||
|
|
||||||
@override
|
|
||||||
String get admin => 'Admin';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loggedIn => 'Logged In';
|
String get loggedIn => 'Logged In';
|
||||||
|
|
||||||
@@ -639,29 +487,11 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get distance => 'Distance';
|
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
|
@override
|
||||||
String directPingTimeout(String name) {
|
String directPingTimeout(String name) {
|
||||||
return 'Direct ping timeout - retrying $name with flooding...';
|
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
|
@override
|
||||||
String pingFailed(String name) {
|
String pingFailed(String name) {
|
||||||
return 'Ping failed to $name - no response received';
|
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.';
|
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
|
@override
|
||||||
String failedToRemoveContact(String error) {
|
String failedToRemoveContact(String error) {
|
||||||
return 'Failed to remove contact: $error';
|
return 'Failed to remove contact: $error';
|
||||||
@@ -726,11 +546,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get telemetry => 'Telemetry';
|
String get telemetry => 'Telemetry';
|
||||||
|
|
||||||
@override
|
|
||||||
String requestingTelemetry(String name) {
|
|
||||||
return 'Requesting telemetry from $name...';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get voltage => 'Voltage';
|
String get voltage => 'Voltage';
|
||||||
|
|
||||||
@@ -802,9 +617,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get showAll => 'Show All';
|
String get showAll => 'Show All';
|
||||||
|
|
||||||
@override
|
|
||||||
String get nearbyContacts => 'Nearby Contacts';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationUnavailable => 'Location unavailable';
|
String get locationUnavailable => 'Location unavailable';
|
||||||
|
|
||||||
@@ -845,173 +657,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get channels => 'Channels';
|
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
|
@override
|
||||||
String get selectMapLayer => 'Select Map Layer';
|
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
|
@override
|
||||||
String get openStreetMap => 'OpenStreetMap';
|
String get openStreetMap => 'OpenStreetMap';
|
||||||
|
|
||||||
@@ -1030,12 +678,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get googleTerrain => 'Google Terrain';
|
String get googleTerrain => 'Google Terrain';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadVisibleArea => 'Download visible area';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get initializingMap => 'Initializing map...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dragToPosition => 'Drag to Position';
|
String get dragToPosition => 'Drag to Position';
|
||||||
|
|
||||||
@@ -1051,50 +693,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarAlert => 'SAR ALERT';
|
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
|
@override
|
||||||
String get textCopiedToClipboard => 'Text copied to clipboard';
|
String get textCopiedToClipboard => 'Text copied to clipboard';
|
||||||
|
|
||||||
@@ -1105,9 +703,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get cannotReplyContactNotFound => 'Cannot reply: contact not found';
|
String get cannotReplyContactNotFound => 'Cannot reply: contact not found';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messageDeleted => 'Message deleted';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get copyText => 'Copy text';
|
String get copyText => 'Copy text';
|
||||||
|
|
||||||
@@ -1143,12 +738,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarLocationShare => 'SAR Location';
|
String get sarLocationShare => 'SAR Location';
|
||||||
|
|
||||||
@override
|
|
||||||
String get locationShared => 'Location shared';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get refreshedContacts => 'Refreshed contacts';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get justNow => 'Just now';
|
String get justNow => 'Just now';
|
||||||
|
|
||||||
@@ -1256,79 +845,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get you => 'You';
|
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
|
@override
|
||||||
String exportFailed(String error) {
|
String exportFailed(String error) {
|
||||||
return 'Export failed: $error';
|
return 'Export failed: $error';
|
||||||
@@ -1339,40 +855,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Import failed: $error';
|
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
|
@override
|
||||||
String get unknown => 'Unknown';
|
String get unknown => 'Unknown';
|
||||||
|
|
||||||
@override
|
|
||||||
String get bounds => 'Bounds';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onlineLayers => 'Online Layers';
|
String get onlineLayers => 'Online Layers';
|
||||||
|
|
||||||
@override
|
|
||||||
String get offlineLayers => 'Offline Layers';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationTrail => 'Location Trail';
|
String get locationTrail => 'Location Trail';
|
||||||
|
|
||||||
@@ -1411,47 +899,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trailControls => 'Trail Controls';
|
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
|
@override
|
||||||
String get contactTrails => 'Contact Trails';
|
String get contactTrails => 'Contact Trails';
|
||||||
|
|
||||||
@@ -1721,19 +1168,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noRoomsFound => 'No rooms found';
|
String get noRoomsFound => 'No rooms found';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noContactsOrRoomsAvailable => 'No contacts or rooms available';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noRecipientsAvailable => 'No recipients available';
|
String get noRecipientsAvailable => 'No recipients available';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noChannelsFound => 'No channels found';
|
String get noChannelsFound => 'No channels found';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messagesWillBeSentToPublicChannel =>
|
|
||||||
'Messages will be sent to public channel';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newMessage => 'New message';
|
String get newMessage => 'New message';
|
||||||
|
|
||||||
@@ -1908,9 +1348,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rectangleDrawing => 'Rectangle Drawing';
|
String get rectangleDrawing => 'Rectangle Drawing';
|
||||||
|
|
||||||
@override
|
|
||||||
String get coordinatesCopiedToClipboard => 'Coordinates copied to clipboard';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get manualCoordinates => 'Manual Coordinates';
|
String get manualCoordinates => 'Manual Coordinates';
|
||||||
|
|
||||||
@@ -1923,37 +1360,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get longitudeLabel => 'Longitude';
|
String get longitudeLabel => 'Longitude';
|
||||||
|
|
||||||
@override
|
|
||||||
String get invalidLatitude => 'Invalid latitude (-90 to 90)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get invalidLongitude => 'Invalid longitude (-180 to 180)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get exampleCoordinates => 'Example: 46.0569, 14.5058';
|
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
|
@override
|
||||||
String get shareDrawing => 'Share Drawing';
|
String get shareDrawing => 'Share Drawing';
|
||||||
|
|
||||||
@@ -2008,12 +1417,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forestRoads => 'Forest Roads';
|
String get forestRoads => 'Forest Roads';
|
||||||
|
|
||||||
@override
|
|
||||||
String get showCadastralParcels => 'Show Cadastral Parcels';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get showForestRoads => 'Show Forest Roads';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get wmsOverlays => 'WMS Overlays';
|
String get wmsOverlays => 'WMS Overlays';
|
||||||
|
|
||||||
@@ -2117,10 +1520,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
return 'Failed to delete channel: $error';
|
return 'Failed to delete channel: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get allChannelSlotsInUse =>
|
|
||||||
'All channel slots are in use (maximum 39 custom channels)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get createChannel => 'Create Channel';
|
String get createChannel => 'Create Channel';
|
||||||
|
|
||||||
@@ -2160,21 +1559,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get wizardConnectingFeature3 =>
|
String get wizardConnectingFeature3 =>
|
||||||
'Works completely offline - no internet required';
|
'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
|
@override
|
||||||
String get wizardChannelTitle => 'Channels';
|
String get wizardChannelTitle => 'Channels';
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get disconnect => 'Desconectar';
|
String get disconnect => 'Desconectar';
|
||||||
|
|
||||||
@override
|
|
||||||
String get scanningForDevices => 'Buscando dispositivos...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noDevicesFound => 'No se encontraron dispositivos';
|
String get noDevicesFound => 'No se encontraron dispositivos';
|
||||||
|
|
||||||
@@ -62,27 +59,14 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get failedToGetGpsLocation => 'Error al obtener la ubicación GPS';
|
String get failedToGetGpsLocation => 'Error al obtener la ubicación GPS';
|
||||||
|
|
||||||
@override
|
|
||||||
String advertisedAtLocation(String latitude, String longitude) {
|
|
||||||
return 'Anunciado en $latitude, $longitude';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToAdvertise(String error) {
|
String failedToAdvertise(String error) {
|
||||||
return 'Error al anunciar: $error';
|
return 'Error al anunciar: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String reconnecting(int attempt, int max) {
|
|
||||||
return 'Reconectando... ($attempt/$max)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cancelReconnection => 'Cancelar reconexión';
|
String get cancelReconnection => 'Cancelar reconexión';
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapManagement => 'Gestión de mapas';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get general => 'General';
|
String get general => 'General';
|
||||||
|
|
||||||
@@ -129,13 +113,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get displayPacketActivity =>
|
String get displayPacketActivity =>
|
||||||
'Mostrar indicadores de actividad de paquetes en la barra superior';
|
'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
|
@override
|
||||||
String get disableMap => 'Desactivar mapa';
|
String get disableMap => 'Desactivar mapa';
|
||||||
|
|
||||||
@@ -149,75 +126,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get chooseLanguage => 'Elegir idioma';
|
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
|
@override
|
||||||
String get save => 'Guardar';
|
String get save => 'Guardar';
|
||||||
|
|
||||||
@@ -253,12 +161,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get moreInfo => 'Más información';
|
String get moreInfo => 'Más información';
|
||||||
|
|
||||||
@override
|
|
||||||
String get learnMoreAbout => 'Más información sobre MeshCore SAR';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get developer => 'Desarrollador';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get packageName => 'Nombre del paquete';
|
String get packageName => 'Nombre del paquete';
|
||||||
|
|
||||||
@@ -334,9 +236,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get refresh => 'Actualizar';
|
String get refresh => 'Actualizar';
|
||||||
|
|
||||||
@override
|
|
||||||
String get sendDirectMessage => 'Enviar';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get resetPath => 'Restablecer ruta (Re-enrutar)';
|
String get resetPath => 'Restablecer ruta (Re-enrutar)';
|
||||||
|
|
||||||
@@ -459,21 +358,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return 'Compartir $count dibujo$plural';
|
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
|
@override
|
||||||
String get showReceivedDrawings => 'Mostrar dibujos recibidos';
|
String get showReceivedDrawings => 'Mostrar dibujos recibidos';
|
||||||
|
|
||||||
@@ -495,9 +379,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearAll => 'Borrar todo';
|
String get clearAll => 'Borrar todo';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noLocalDrawings => 'No hay dibujos locales para compartir';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicChannel => 'Canal público';
|
String get publicChannel => 'Canal público';
|
||||||
|
|
||||||
@@ -507,37 +388,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storedPermanently => 'Almacenado permanentemente en la sala';
|
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
|
@override
|
||||||
String get notConnectedToDevice => 'No conectado al dispositivo';
|
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
|
@override
|
||||||
String get typeYourMessage => 'Escribe tu mensaje...';
|
String get typeYourMessage => 'Escribe tu mensaje...';
|
||||||
|
|
||||||
@@ -567,9 +420,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get location => 'Ubicación';
|
String get location => 'Ubicación';
|
||||||
|
|
||||||
@override
|
|
||||||
String get myLocation => 'Mi ubicación';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fromMap => 'Desde el mapa';
|
String get fromMap => 'Desde el mapa';
|
||||||
|
|
||||||
@@ -631,9 +481,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get flood => 'Inundación';
|
String get flood => 'Inundación';
|
||||||
|
|
||||||
@override
|
|
||||||
String get admin => 'Admin';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loggedIn => 'Sesión iniciada';
|
String get loggedIn => 'Sesión iniciada';
|
||||||
|
|
||||||
@@ -643,29 +490,11 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get distance => 'Distancia';
|
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
|
@override
|
||||||
String directPingTimeout(String name) {
|
String directPingTimeout(String name) {
|
||||||
return 'Tiempo de espera de ping directo agotado - reintentando $name con inundación...';
|
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
|
@override
|
||||||
String pingFailed(String name) {
|
String pingFailed(String name) {
|
||||||
return 'Ping fallido a $name - no se recibió respuesta';
|
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.';
|
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
|
@override
|
||||||
String failedToRemoveContact(String error) {
|
String failedToRemoveContact(String error) {
|
||||||
return 'Error al eliminar contacto: $error';
|
return 'Error al eliminar contacto: $error';
|
||||||
@@ -730,11 +549,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get telemetry => 'Telemetría';
|
String get telemetry => 'Telemetría';
|
||||||
|
|
||||||
@override
|
|
||||||
String requestingTelemetry(String name) {
|
|
||||||
return 'Solicitando telemetría de $name...';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get voltage => 'Voltaje';
|
String get voltage => 'Voltaje';
|
||||||
|
|
||||||
@@ -806,9 +620,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get showAll => 'Mostrar todo';
|
String get showAll => 'Mostrar todo';
|
||||||
|
|
||||||
@override
|
|
||||||
String get nearbyContacts => 'Contactos cercanos';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationUnavailable => 'Ubicación no disponible';
|
String get locationUnavailable => 'Ubicación no disponible';
|
||||||
|
|
||||||
@@ -849,176 +660,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get channels => 'Canales';
|
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
|
@override
|
||||||
String get selectMapLayer => 'Seleccionar capa de mapa';
|
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
|
@override
|
||||||
String get openStreetMap => 'OpenStreetMap';
|
String get openStreetMap => 'OpenStreetMap';
|
||||||
|
|
||||||
@@ -1037,12 +681,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get googleTerrain => 'Google Terreno';
|
String get googleTerrain => 'Google Terreno';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadVisibleArea => 'Descargar área visible';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get initializingMap => 'Inicializando mapa...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dragToPosition => 'Arrastrar a posición';
|
String get dragToPosition => 'Arrastrar a posición';
|
||||||
|
|
||||||
@@ -1058,51 +696,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarAlert => 'ALERTA SAR';
|
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
|
@override
|
||||||
String get textCopiedToClipboard => 'Texto copiado al portapapeles';
|
String get textCopiedToClipboard => 'Texto copiado al portapapeles';
|
||||||
|
|
||||||
@@ -1114,9 +707,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get cannotReplyContactNotFound =>
|
String get cannotReplyContactNotFound =>
|
||||||
'No se puede responder: contacto no encontrado';
|
'No se puede responder: contacto no encontrado';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messageDeleted => 'Mensaje eliminado';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get copyText => 'Copiar texto';
|
String get copyText => 'Copiar texto';
|
||||||
|
|
||||||
@@ -1152,12 +742,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarLocationShare => 'Ubicación SAR';
|
String get sarLocationShare => 'Ubicación SAR';
|
||||||
|
|
||||||
@override
|
|
||||||
String get locationShared => 'Ubicación compartida';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get refreshedContacts => 'Contactos actualizados';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get justNow => 'Justo ahora';
|
String get justNow => 'Justo ahora';
|
||||||
|
|
||||||
@@ -1265,125 +849,22 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get you => 'Tú';
|
String get you => 'Tú';
|
||||||
|
|
||||||
@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
|
@override
|
||||||
String exportFailed(String error) {
|
String exportFailed(String error) {
|
||||||
return 'Export failed: $error';
|
return 'Error al exportar: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String importFailed(String error) {
|
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
|
@override
|
||||||
String get unknown => 'Desconocido';
|
String get unknown => 'Desconocido';
|
||||||
|
|
||||||
@override
|
|
||||||
String get bounds => 'Límites';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onlineLayers => 'Capas en línea';
|
String get onlineLayers => 'Capas en línea';
|
||||||
|
|
||||||
@override
|
|
||||||
String get offlineLayers => 'Capas sin conexión';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationTrail => 'Rastro de ubicación';
|
String get locationTrail => 'Rastro de ubicación';
|
||||||
|
|
||||||
@@ -1422,47 +903,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trailControls => 'Controles del rastro';
|
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
|
@override
|
||||||
String get contactTrails => 'Rastros de contactos';
|
String get contactTrails => 'Rastros de contactos';
|
||||||
|
|
||||||
@@ -1583,7 +1023,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get templateName => 'Nombre de plantilla';
|
String get templateName => 'Nombre de plantilla';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateNameHint => 'e.g. Found Person';
|
String get templateNameHint => 'p. ej., Persona encontrada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateEmoji => 'Emoji';
|
String get templateEmoji => 'Emoji';
|
||||||
@@ -1595,16 +1035,16 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get nameRequired => 'Se requiere nombre';
|
String get nameRequired => 'Se requiere nombre';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDescription => 'Description (Optional)';
|
String get templateDescription => 'Descripcion (opcional)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDescriptionHint => 'Add additional context...';
|
String get templateDescriptionHint => 'Anade contexto adicional...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateColor => 'Color';
|
String get templateColor => 'Color';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get previewFormat => 'Preview (SAR Message Format)';
|
String get previewFormat => 'Vista previa (formato de mensaje SAR)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get importFromClipboard => 'Importar';
|
String get importFromClipboard => 'Importar';
|
||||||
@@ -1614,26 +1054,26 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String deleteTemplateConfirmation(String name) {
|
String deleteTemplateConfirmation(String name) {
|
||||||
return 'Delete template \'$name\'?';
|
return '¿Eliminar la plantilla \'$name\'?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateAdded => 'Template added';
|
String get templateAdded => 'Plantilla anadida';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateUpdated => 'Template updated';
|
String get templateUpdated => 'Plantilla actualizada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDeleted => 'Template deleted';
|
String get templateDeleted => 'Plantilla eliminada';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String templatesImported(int count) {
|
String templatesImported(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'Imported $count templates',
|
other: 'Se importaron $count plantillas',
|
||||||
one: 'Imported 1 template',
|
one: 'Se importo 1 plantilla',
|
||||||
zero: 'No templates imported',
|
zero: 'No se importaron plantillas',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -1643,8 +1083,8 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'Exported $count templates to clipboard',
|
other: 'Se exportaron $count plantillas al portapapeles',
|
||||||
one: 'Exported 1 template to clipboard',
|
one: 'Se exporto 1 plantilla al portapapeles',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -1664,10 +1104,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
'Plantillas restablecidas a valores predeterminados';
|
'Plantillas restablecidas a valores predeterminados';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noTemplates => 'No templates available';
|
String get noTemplates => 'No hay plantillas disponibles';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tapAddToCreate => 'Tap + to create your first template';
|
String get tapAddToCreate => 'Toca + para crear tu primera plantilla';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ok => 'OK';
|
String get ok => 'OK';
|
||||||
@@ -1735,20 +1175,12 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noRoomsFound => 'No se encontraron salas';
|
String get noRoomsFound => 'No se encontraron salas';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noContactsOrRoomsAvailable =>
|
|
||||||
'No hay contactos o salas disponibles';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noRecipientsAvailable => 'No hay destinatarios disponibles';
|
String get noRecipientsAvailable => 'No hay destinatarios disponibles';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noChannelsFound => 'No se encontraron canales';
|
String get noChannelsFound => 'No se encontraron canales';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messagesWillBeSentToPublicChannel =>
|
|
||||||
'Los mensajes se enviarán al canal público';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newMessage => 'Nuevo mensaje';
|
String get newMessage => 'Nuevo mensaje';
|
||||||
|
|
||||||
@@ -1936,10 +1368,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rectangleDrawing => 'Rectángulo';
|
String get rectangleDrawing => 'Rectángulo';
|
||||||
|
|
||||||
@override
|
|
||||||
String get coordinatesCopiedToClipboard =>
|
|
||||||
'Coordenadas copiadas al portapapeles';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get manualCoordinates => 'Coordenadas Manuales';
|
String get manualCoordinates => 'Coordenadas Manuales';
|
||||||
|
|
||||||
@@ -1952,31 +1380,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get longitudeLabel => 'Longitud';
|
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
|
@override
|
||||||
String get exampleCoordinates => 'Ejemplo: 46.0569, 14.5058';
|
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
|
@override
|
||||||
String get shareDrawing => 'Compartir Dibujo';
|
String get shareDrawing => 'Compartir Dibujo';
|
||||||
|
|
||||||
@@ -2033,12 +1439,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forestRoads => 'Caminos Forestales';
|
String get forestRoads => 'Caminos Forestales';
|
||||||
|
|
||||||
@override
|
|
||||||
String get showCadastralParcels => 'Mostrar parcelas catastrales';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get showForestRoads => 'Mostrar caminos forestales';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get wmsOverlays => 'Superposiciones WMS';
|
String get wmsOverlays => 'Superposiciones WMS';
|
||||||
|
|
||||||
@@ -2143,10 +1543,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
return 'Error al eliminar el canal: $error';
|
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
|
@override
|
||||||
String get createChannel => 'Crear Canal';
|
String get createChannel => 'Crear Canal';
|
||||||
|
|
||||||
@@ -2187,21 +1583,6 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get wizardConnectingFeature3 =>
|
String get wizardConnectingFeature3 =>
|
||||||
'Funciona completamente sin conexión - no se requiere internet';
|
'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
|
@override
|
||||||
String get wizardChannelTitle => 'Canales';
|
String get wizardChannelTitle => 'Canales';
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get disconnect => 'Déconnecter';
|
String get disconnect => 'Déconnecter';
|
||||||
|
|
||||||
@override
|
|
||||||
String get scanningForDevices => 'Recherche d\'appareils...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noDevicesFound => 'Aucun appareil trouvé';
|
String get noDevicesFound => 'Aucun appareil trouvé';
|
||||||
|
|
||||||
@@ -63,27 +60,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get failedToGetGpsLocation =>
|
String get failedToGetGpsLocation =>
|
||||||
'Échec de l\'obtention de la position GPS';
|
'Échec de l\'obtention de la position GPS';
|
||||||
|
|
||||||
@override
|
|
||||||
String advertisedAtLocation(String latitude, String longitude) {
|
|
||||||
return 'Annoncé à $latitude, $longitude';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToAdvertise(String error) {
|
String failedToAdvertise(String error) {
|
||||||
return 'Échec de l\'annonce : $error';
|
return 'Échec de l\'annonce : $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String reconnecting(int attempt, int max) {
|
|
||||||
return 'Reconnexion... ($attempt/$max)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cancelReconnection => 'Annuler la reconnexion';
|
String get cancelReconnection => 'Annuler la reconnexion';
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapManagement => 'Gestion des cartes';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get general => 'Général';
|
String get general => 'Général';
|
||||||
|
|
||||||
@@ -130,13 +114,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get displayPacketActivity =>
|
String get displayPacketActivity =>
|
||||||
'Afficher les indicateurs d\'activité des paquets dans la barre supérieure';
|
'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
|
@override
|
||||||
String get disableMap => 'Désactiver la carte';
|
String get disableMap => 'Désactiver la carte';
|
||||||
|
|
||||||
@@ -150,75 +127,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get chooseLanguage => 'Choisir la langue';
|
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
|
@override
|
||||||
String get save => 'Enregistrer';
|
String get save => 'Enregistrer';
|
||||||
|
|
||||||
@@ -254,12 +162,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get moreInfo => 'Plus d\'infos';
|
String get moreInfo => 'Plus d\'infos';
|
||||||
|
|
||||||
@override
|
|
||||||
String get learnMoreAbout => 'En savoir plus sur MeshCore SAR';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get developer => 'Développeur';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get packageName => 'Nom du package';
|
String get packageName => 'Nom du package';
|
||||||
|
|
||||||
@@ -336,9 +238,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get refresh => 'Actualiser';
|
String get refresh => 'Actualiser';
|
||||||
|
|
||||||
@override
|
|
||||||
String get sendDirectMessage => 'Envoyer';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get resetPath => 'Réinitialiser le chemin (Re-router)';
|
String get resetPath => 'Réinitialiser le chemin (Re-router)';
|
||||||
|
|
||||||
@@ -459,21 +358,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Partager $count dessin$plural';
|
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
|
@override
|
||||||
String get showReceivedDrawings => 'Afficher les dessins reçus';
|
String get showReceivedDrawings => 'Afficher les dessins reçus';
|
||||||
|
|
||||||
@@ -495,9 +379,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearAll => 'Tout effacer';
|
String get clearAll => 'Tout effacer';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noLocalDrawings => 'Aucun dessin local à partager';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicChannel => 'Canal public';
|
String get publicChannel => 'Canal public';
|
||||||
|
|
||||||
@@ -508,37 +389,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storedPermanently => 'Stocké de manière permanente dans le salon';
|
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
|
@override
|
||||||
String get notConnectedToDevice => 'Non connecté à l\'appareil';
|
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
|
@override
|
||||||
String get typeYourMessage => 'Saisissez votre message...';
|
String get typeYourMessage => 'Saisissez votre message...';
|
||||||
|
|
||||||
@@ -568,9 +421,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get location => 'Position';
|
String get location => 'Position';
|
||||||
|
|
||||||
@override
|
|
||||||
String get myLocation => 'Ma position';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fromMap => 'Depuis la carte';
|
String get fromMap => 'Depuis la carte';
|
||||||
|
|
||||||
@@ -633,9 +483,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get flood => 'Inondation';
|
String get flood => 'Inondation';
|
||||||
|
|
||||||
@override
|
|
||||||
String get admin => 'Admin';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loggedIn => 'Connecté';
|
String get loggedIn => 'Connecté';
|
||||||
|
|
||||||
@@ -645,29 +492,11 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get distance => 'Distance';
|
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
|
@override
|
||||||
String directPingTimeout(String name) {
|
String directPingTimeout(String name) {
|
||||||
return 'Délai d\'attente du ping direct - nouvelle tentative de $name avec inondation...';
|
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
|
@override
|
||||||
String pingFailed(String name) {
|
String pingFailed(String name) {
|
||||||
return 'Échec du ping vers $name - aucune réponse reçue';
|
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.';
|
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
|
@override
|
||||||
String failedToRemoveContact(String error) {
|
String failedToRemoveContact(String error) {
|
||||||
return 'Échec de la suppression du contact : $error';
|
return 'Échec de la suppression du contact : $error';
|
||||||
@@ -732,11 +551,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get telemetry => 'Télémétrie';
|
String get telemetry => 'Télémétrie';
|
||||||
|
|
||||||
@override
|
|
||||||
String requestingTelemetry(String name) {
|
|
||||||
return 'Demande de télémétrie à $name...';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get voltage => 'Tension';
|
String get voltage => 'Tension';
|
||||||
|
|
||||||
@@ -808,9 +622,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get showAll => 'Tout afficher';
|
String get showAll => 'Tout afficher';
|
||||||
|
|
||||||
@override
|
|
||||||
String get nearbyContacts => 'Contacts à proximité';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationUnavailable => 'Position non disponible';
|
String get locationUnavailable => 'Position non disponible';
|
||||||
|
|
||||||
@@ -851,177 +662,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get channels => 'Canaux';
|
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
|
@override
|
||||||
String get selectMapLayer => 'Sélectionner la couche de carte';
|
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
|
@override
|
||||||
String get openStreetMap => 'OpenStreetMap';
|
String get openStreetMap => 'OpenStreetMap';
|
||||||
|
|
||||||
@@ -1040,12 +683,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get googleTerrain => 'Google Terrain';
|
String get googleTerrain => 'Google Terrain';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadVisibleArea => 'Télécharger la zone visible';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get initializingMap => 'Initialisation de la carte...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dragToPosition => 'Faire glisser vers la position';
|
String get dragToPosition => 'Faire glisser vers la position';
|
||||||
|
|
||||||
@@ -1061,51 +698,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarAlert => 'ALERTE SAR';
|
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
|
@override
|
||||||
String get textCopiedToClipboard => 'Texte copié dans le presse-papiers';
|
String get textCopiedToClipboard => 'Texte copié dans le presse-papiers';
|
||||||
|
|
||||||
@@ -1117,9 +709,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get cannotReplyContactNotFound =>
|
String get cannotReplyContactNotFound =>
|
||||||
'Impossible de répondre : contact non trouvé';
|
'Impossible de répondre : contact non trouvé';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messageDeleted => 'Message supprimé';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get copyText => 'Copier le texte';
|
String get copyText => 'Copier le texte';
|
||||||
|
|
||||||
@@ -1155,12 +744,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarLocationShare => 'Position SAR';
|
String get sarLocationShare => 'Position SAR';
|
||||||
|
|
||||||
@override
|
|
||||||
String get locationShared => 'Position partagée';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get refreshedContacts => 'Contacts actualisés';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get justNow => 'À l\'instant';
|
String get justNow => 'À l\'instant';
|
||||||
|
|
||||||
@@ -1268,127 +851,22 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get you => 'Vous';
|
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
|
@override
|
||||||
String exportFailed(String error) {
|
String exportFailed(String error) {
|
||||||
return 'Export failed: $error';
|
return 'Echec de l\'exportation : $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String importFailed(String error) {
|
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
|
@override
|
||||||
String get unknown => 'Inconnu';
|
String get unknown => 'Inconnu';
|
||||||
|
|
||||||
@override
|
|
||||||
String get bounds => 'Limites';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onlineLayers => 'Couches en ligne';
|
String get onlineLayers => 'Couches en ligne';
|
||||||
|
|
||||||
@override
|
|
||||||
String get offlineLayers => 'Couches hors ligne';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationTrail => 'Trace de déplacement';
|
String get locationTrail => 'Trace de déplacement';
|
||||||
|
|
||||||
@@ -1428,47 +906,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trailControls => 'Contrôles de la trace';
|
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
|
@override
|
||||||
String get contactTrails => 'Traces des contacts';
|
String get contactTrails => 'Traces des contacts';
|
||||||
|
|
||||||
@@ -1589,7 +1026,7 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get templateName => 'Nom du modèle';
|
String get templateName => 'Nom du modèle';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateNameHint => 'e.g. Found Person';
|
String get templateNameHint => 'p. ex. Personne trouvee';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateEmoji => 'Emoji';
|
String get templateEmoji => 'Emoji';
|
||||||
@@ -1601,16 +1038,16 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get nameRequired => 'Nom est requis';
|
String get nameRequired => 'Nom est requis';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDescription => 'Description (Optional)';
|
String get templateDescription => 'Description (facultative)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDescriptionHint => 'Add additional context...';
|
String get templateDescriptionHint => 'Ajoutez un contexte supplementaire...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateColor => 'Color';
|
String get templateColor => 'Couleur';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get previewFormat => 'Preview (SAR Message Format)';
|
String get previewFormat => 'Apercu (format de message SAR)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get importFromClipboard => 'Importer';
|
String get importFromClipboard => 'Importer';
|
||||||
@@ -1620,26 +1057,26 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String deleteTemplateConfirmation(String name) {
|
String deleteTemplateConfirmation(String name) {
|
||||||
return 'Delete template \'$name\'?';
|
return 'Supprimer le modele \'$name\' ?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateAdded => 'Template added';
|
String get templateAdded => 'Modele ajoute';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateUpdated => 'Template updated';
|
String get templateUpdated => 'Modele mis a jour';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDeleted => 'Template deleted';
|
String get templateDeleted => 'Modele supprime';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String templatesImported(int count) {
|
String templatesImported(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'Imported $count templates',
|
other: '$count modeles importes',
|
||||||
one: 'Imported 1 template',
|
one: '1 modele importe',
|
||||||
zero: 'No templates imported',
|
zero: 'Aucun modele importe',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -1649,8 +1086,8 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'Exported $count templates to clipboard',
|
other: '$count modeles exportes vers le presse-papiers',
|
||||||
one: 'Exported 1 template to clipboard',
|
one: '1 modele exporte vers le presse-papiers',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -1669,10 +1106,10 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get resetComplete => 'Modèles réinitialisés aux valeurs par défaut';
|
String get resetComplete => 'Modèles réinitialisés aux valeurs par défaut';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noTemplates => 'No templates available';
|
String get noTemplates => 'Aucun modele disponible';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tapAddToCreate => 'Tap + to create your first template';
|
String get tapAddToCreate => 'Appuyez sur + pour creer votre premier modele';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ok => 'OK';
|
String get ok => 'OK';
|
||||||
@@ -1741,19 +1178,12 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noRoomsFound => 'Aucune salle trouvée';
|
String get noRoomsFound => 'Aucune salle trouvée';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noContactsOrRoomsAvailable => 'Aucun contact ou salle disponible';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noRecipientsAvailable => 'Aucun destinataire disponible';
|
String get noRecipientsAvailable => 'Aucun destinataire disponible';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noChannelsFound => 'Aucun canal trouvé';
|
String get noChannelsFound => 'Aucun canal trouvé';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messagesWillBeSentToPublicChannel =>
|
|
||||||
'Les messages seront envoyés au canal public';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newMessage => 'Nouveau message';
|
String get newMessage => 'Nouveau message';
|
||||||
|
|
||||||
@@ -1940,10 +1370,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rectangleDrawing => 'Rectangle';
|
String get rectangleDrawing => 'Rectangle';
|
||||||
|
|
||||||
@override
|
|
||||||
String get coordinatesCopiedToClipboard =>
|
|
||||||
'Coordonnées copiées dans le presse-papiers';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get manualCoordinates => 'Coordonnées Manuelles';
|
String get manualCoordinates => 'Coordonnées Manuelles';
|
||||||
|
|
||||||
@@ -1956,31 +1382,9 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get longitudeLabel => 'Longitude';
|
String get longitudeLabel => 'Longitude';
|
||||||
|
|
||||||
@override
|
|
||||||
String get invalidLatitude => 'Latitude invalide (-90 à 90)';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get invalidLongitude => 'Longitude invalide (-180 à 180)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get exampleCoordinates => 'Exemple: 46.0569, 14.5058';
|
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
|
@override
|
||||||
String get shareDrawing => 'Partager le Dessin';
|
String get shareDrawing => 'Partager le Dessin';
|
||||||
|
|
||||||
@@ -2037,12 +1441,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forestRoads => 'Chemins Forestiers';
|
String get forestRoads => 'Chemins Forestiers';
|
||||||
|
|
||||||
@override
|
|
||||||
String get showCadastralParcels => 'Afficher les parcelles cadastrales';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get showForestRoads => 'Afficher les chemins forestiers';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get wmsOverlays => 'Superpositions WMS';
|
String get wmsOverlays => 'Superpositions WMS';
|
||||||
|
|
||||||
@@ -2148,10 +1546,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
return 'Échec de la suppression du canal : $error';
|
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
|
@override
|
||||||
String get createChannel => 'Créer un Canal';
|
String get createChannel => 'Créer un Canal';
|
||||||
|
|
||||||
@@ -2193,21 +1587,6 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get wizardConnectingFeature3 =>
|
String get wizardConnectingFeature3 =>
|
||||||
'Fonctionne entièrement hors ligne - aucun internet requis';
|
'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
|
@override
|
||||||
String get wizardChannelTitle => 'Canaux';
|
String get wizardChannelTitle => 'Canaux';
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get disconnect => 'Prekini';
|
String get disconnect => 'Prekini';
|
||||||
|
|
||||||
@override
|
|
||||||
String get scanningForDevices => 'Skeniranje uređaja...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noDevicesFound => 'Nisu pronađeni uređaji';
|
String get noDevicesFound => 'Nisu pronađeni uređaji';
|
||||||
|
|
||||||
@@ -62,27 +59,14 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get failedToGetGpsLocation => 'Neuspjelo dobivanje GPS lokacije';
|
String get failedToGetGpsLocation => 'Neuspjelo dobivanje GPS lokacije';
|
||||||
|
|
||||||
@override
|
|
||||||
String advertisedAtLocation(String latitude, String longitude) {
|
|
||||||
return 'Objavljeno na $latitude, $longitude';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToAdvertise(String error) {
|
String failedToAdvertise(String error) {
|
||||||
return 'Neuspjela objava: $error';
|
return 'Neuspjela objava: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String reconnecting(int attempt, int max) {
|
|
||||||
return 'Ponovno povezivanje... ($attempt/$max)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cancelReconnection => 'Otkaži ponovno povezivanje';
|
String get cancelReconnection => 'Otkaži ponovno povezivanje';
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapManagement => 'Upravljanje kartom';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get general => 'Općenito';
|
String get general => 'Općenito';
|
||||||
|
|
||||||
@@ -129,13 +113,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
String get displayPacketActivity =>
|
String get displayPacketActivity =>
|
||||||
'Prikaži indikatore aktivnosti paketa u gornjoj traci';
|
'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
|
@override
|
||||||
String get disableMap => 'Onemogući kartu';
|
String get disableMap => 'Onemogući kartu';
|
||||||
|
|
||||||
@@ -148,74 +125,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get chooseLanguage => 'Odaberite jezik';
|
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
|
@override
|
||||||
String get save => 'Spremi';
|
String get save => 'Spremi';
|
||||||
|
|
||||||
@@ -251,12 +160,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get moreInfo => 'Više informacija';
|
String get moreInfo => 'Više informacija';
|
||||||
|
|
||||||
@override
|
|
||||||
String get learnMoreAbout => 'Saznajte više o MeshCore SAR-u';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get developer => 'Programer';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get packageName => 'Ime paketa';
|
String get packageName => 'Ime paketa';
|
||||||
|
|
||||||
@@ -332,9 +235,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get refresh => 'Osvježi';
|
String get refresh => 'Osvježi';
|
||||||
|
|
||||||
@override
|
|
||||||
String get sendDirectMessage => 'Pošalji';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get resetPath => 'Resetiraj put (preusmjeri)';
|
String get resetPath => 'Resetiraj put (preusmjeri)';
|
||||||
|
|
||||||
@@ -453,21 +353,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
return 'Podijeli $count crtež$plural';
|
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
|
@override
|
||||||
String get showReceivedDrawings => 'Prikaži primljene crteže';
|
String get showReceivedDrawings => 'Prikaži primljene crteže';
|
||||||
|
|
||||||
@@ -489,9 +374,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearAll => 'Očisti sve';
|
String get clearAll => 'Očisti sve';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noLocalDrawings => 'Nema lokalnih crteža za dijeljenje';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicChannel => 'Javni kanal';
|
String get publicChannel => 'Javni kanal';
|
||||||
|
|
||||||
@@ -501,37 +383,9 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storedPermanently => 'Trajno pohranjeno u sobi';
|
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
|
@override
|
||||||
String get notConnectedToDevice => 'Nije povezano s uređajem';
|
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
|
@override
|
||||||
String get typeYourMessage => 'Upišite svoju poruku...';
|
String get typeYourMessage => 'Upišite svoju poruku...';
|
||||||
|
|
||||||
@@ -561,9 +415,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get location => 'Lokacija';
|
String get location => 'Lokacija';
|
||||||
|
|
||||||
@override
|
|
||||||
String get myLocation => 'Moja lokacija';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fromMap => 'S karte';
|
String get fromMap => 'S karte';
|
||||||
|
|
||||||
@@ -625,9 +476,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get flood => 'Preplavljanje';
|
String get flood => 'Preplavljanje';
|
||||||
|
|
||||||
@override
|
|
||||||
String get admin => 'Administrator';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loggedIn => 'Prijavljen';
|
String get loggedIn => 'Prijavljen';
|
||||||
|
|
||||||
@@ -637,29 +485,11 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get distance => 'Udaljenost';
|
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
|
@override
|
||||||
String directPingTimeout(String name) {
|
String directPingTimeout(String name) {
|
||||||
return 'Istek izravnog pinga - ponovni pokušaj $name s preplavljanjem...';
|
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
|
@override
|
||||||
String pingFailed(String name) {
|
String pingFailed(String name) {
|
||||||
return 'Ping neuspješan prema $name - nije primljen odgovor';
|
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.';
|
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
|
@override
|
||||||
String failedToRemoveContact(String error) {
|
String failedToRemoveContact(String error) {
|
||||||
return 'Neuspjelo uklanjanje kontakta: $error';
|
return 'Neuspjelo uklanjanje kontakta: $error';
|
||||||
@@ -724,11 +544,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get telemetry => 'Telemetrija';
|
String get telemetry => 'Telemetrija';
|
||||||
|
|
||||||
@override
|
|
||||||
String requestingTelemetry(String name) {
|
|
||||||
return 'Zahtijevanje telemetrije od $name...';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get voltage => 'Napon';
|
String get voltage => 'Napon';
|
||||||
|
|
||||||
@@ -800,9 +615,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get showAll => 'Prikaži sve';
|
String get showAll => 'Prikaži sve';
|
||||||
|
|
||||||
@override
|
|
||||||
String get nearbyContacts => 'Obližnji kontakti';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationUnavailable => 'Lokacija nije dostupna';
|
String get locationUnavailable => 'Lokacija nije dostupna';
|
||||||
|
|
||||||
@@ -843,174 +655,9 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get channels => 'Kanali';
|
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
|
@override
|
||||||
String get selectMapLayer => 'Odaberite sloj karte';
|
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
|
@override
|
||||||
String get openStreetMap => 'OpenStreetMap';
|
String get openStreetMap => 'OpenStreetMap';
|
||||||
|
|
||||||
@@ -1029,12 +676,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get googleTerrain => 'Google teren';
|
String get googleTerrain => 'Google teren';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadVisibleArea => 'Preuzmi vidljivo područje';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get initializingMap => 'Inicijalizacija karte...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dragToPosition => 'Povuci na poziciju';
|
String get dragToPosition => 'Povuci na poziciju';
|
||||||
|
|
||||||
@@ -1050,51 +691,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarAlert => 'SAR UZBUNA';
|
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
|
@override
|
||||||
String get textCopiedToClipboard => 'Tekst kopiran u međuspremnik';
|
String get textCopiedToClipboard => 'Tekst kopiran u međuspremnik';
|
||||||
|
|
||||||
@@ -1106,9 +702,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
String get cannotReplyContactNotFound =>
|
String get cannotReplyContactNotFound =>
|
||||||
'Ne mogu odgovoriti: kontakt nije pronađen';
|
'Ne mogu odgovoriti: kontakt nije pronađen';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messageDeleted => 'Poruka izbrisana';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get copyText => 'Kopiraj tekst';
|
String get copyText => 'Kopiraj tekst';
|
||||||
|
|
||||||
@@ -1144,12 +737,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarLocationShare => 'SAR Lokacija';
|
String get sarLocationShare => 'SAR Lokacija';
|
||||||
|
|
||||||
@override
|
|
||||||
String get locationShared => 'Lokacija podijeljena';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get refreshedContacts => 'Kontakti osvježeni';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get justNow => 'Upravo sada';
|
String get justNow => 'Upravo sada';
|
||||||
|
|
||||||
@@ -1257,78 +844,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get you => 'Ti';
|
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
|
@override
|
||||||
String exportFailed(String error) {
|
String exportFailed(String error) {
|
||||||
return 'Izvoz nije uspio: $error';
|
return 'Izvoz nije uspio: $error';
|
||||||
@@ -1339,40 +854,12 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
return 'Uvoz nije uspio: $error';
|
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
|
@override
|
||||||
String get unknown => 'Nepoznato';
|
String get unknown => 'Nepoznato';
|
||||||
|
|
||||||
@override
|
|
||||||
String get bounds => 'Granice';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onlineLayers => 'Mrežni slojevi';
|
String get onlineLayers => 'Mrežni slojevi';
|
||||||
|
|
||||||
@override
|
|
||||||
String get offlineLayers => 'Offline slojevi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationTrail => 'Putanja lokacije';
|
String get locationTrail => 'Putanja lokacije';
|
||||||
|
|
||||||
@@ -1411,47 +898,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trailControls => 'Upravljanje putanjom';
|
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
|
@override
|
||||||
String get contactTrails => 'Putanje kontakata';
|
String get contactTrails => 'Putanje kontakata';
|
||||||
|
|
||||||
@@ -1492,7 +938,7 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
String get buildDate => 'Datum izgradnje';
|
String get buildDate => 'Datum izgradnje';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get firmware => 'Firmware';
|
String get firmware => 'Firmver';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get maxContacts => 'Maks. kontakata';
|
String get maxContacts => 'Maks. kontakata';
|
||||||
@@ -1658,7 +1104,7 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
String get tapAddToCreate => 'Dodirnite + za izradu prvog predloška';
|
String get tapAddToCreate => 'Dodirnite + za izradu prvog predloška';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ok => 'OK';
|
String get ok => 'U redu';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get permissionsSection => 'Dozvole';
|
String get permissionsSection => 'Dozvole';
|
||||||
@@ -1723,19 +1169,12 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noRoomsFound => 'Nema soba';
|
String get noRoomsFound => 'Nema soba';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noContactsOrRoomsAvailable => 'Nema dostupnih kontakata ili soba';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noRecipientsAvailable => 'Nema dostupnih primatelja';
|
String get noRecipientsAvailable => 'Nema dostupnih primatelja';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noChannelsFound => 'Nije pronađen nijedan kanal';
|
String get noChannelsFound => 'Nije pronađen nijedan kanal';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messagesWillBeSentToPublicChannel =>
|
|
||||||
'Poruke će biti poslane na javni kanal';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newMessage => 'Nova poruka';
|
String get newMessage => 'Nova poruka';
|
||||||
|
|
||||||
@@ -1915,10 +1354,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rectangleDrawing => 'Pravokutni crtež';
|
String get rectangleDrawing => 'Pravokutni crtež';
|
||||||
|
|
||||||
@override
|
|
||||||
String get coordinatesCopiedToClipboard =>
|
|
||||||
'Koordinate kopirane u međuspremnik';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get manualCoordinates => 'Ručne koordinate';
|
String get manualCoordinates => 'Ručne koordinate';
|
||||||
|
|
||||||
@@ -1931,37 +1366,9 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get longitudeLabel => 'Geografska dužina';
|
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
|
@override
|
||||||
String get exampleCoordinates => 'Primjer: 46.0569, 14.5058';
|
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
|
@override
|
||||||
String get shareDrawing => 'Podijeli crtež';
|
String get shareDrawing => 'Podijeli crtež';
|
||||||
|
|
||||||
@@ -2017,12 +1424,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forestRoads => 'Šumske ceste';
|
String get forestRoads => 'Šumske ceste';
|
||||||
|
|
||||||
@override
|
|
||||||
String get showCadastralParcels => 'Prikaži katastarske čestice';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get showForestRoads => 'Prikaži šumske ceste';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get wmsOverlays => 'WMS prekrivanja';
|
String get wmsOverlays => 'WMS prekrivanja';
|
||||||
|
|
||||||
@@ -2126,10 +1527,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
return 'Neuspješno brisanje kanala: $error';
|
return 'Neuspješno brisanje kanala: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get allChannelSlotsInUse =>
|
|
||||||
'Svi slotovi kanala su zauzeti (maksimalno 39 prilagođenih kanala)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get createChannel => 'Kreiraj kanal';
|
String get createChannel => 'Kreiraj kanal';
|
||||||
|
|
||||||
@@ -2170,21 +1567,6 @@ class AppLocalizationsHr extends AppLocalizations {
|
|||||||
String get wizardConnectingFeature3 =>
|
String get wizardConnectingFeature3 =>
|
||||||
'Radi potpuno izvan mreže — internet nije potreban';
|
'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
|
@override
|
||||||
String get wizardChannelTitle => 'Kanali';
|
String get wizardChannelTitle => 'Kanali';
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get disconnect => 'Disconnetti';
|
String get disconnect => 'Disconnetti';
|
||||||
|
|
||||||
@override
|
|
||||||
String get scanningForDevices => 'Ricerca dispositivi in corso...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noDevicesFound => 'Nessun dispositivo trovato';
|
String get noDevicesFound => 'Nessun dispositivo trovato';
|
||||||
|
|
||||||
@@ -62,27 +59,14 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get failedToGetGpsLocation => 'Impossibile ottenere la posizione GPS';
|
String get failedToGetGpsLocation => 'Impossibile ottenere la posizione GPS';
|
||||||
|
|
||||||
@override
|
|
||||||
String advertisedAtLocation(String latitude, String longitude) {
|
|
||||||
return 'Annunciato a $latitude, $longitude';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToAdvertise(String error) {
|
String failedToAdvertise(String error) {
|
||||||
return 'Annuncio fallito: $error';
|
return 'Annuncio fallito: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String reconnecting(int attempt, int max) {
|
|
||||||
return 'Riconnessione in corso... ($attempt/$max)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cancelReconnection => 'Annulla riconnessione';
|
String get cancelReconnection => 'Annulla riconnessione';
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapManagement => 'Gestione Mappa';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get general => 'Generale';
|
String get general => 'Generale';
|
||||||
|
|
||||||
@@ -129,13 +113,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String get displayPacketActivity =>
|
String get displayPacketActivity =>
|
||||||
'Mostra indicatori di attività pacchetti nella barra superiore';
|
'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
|
@override
|
||||||
String get disableMap => 'Disabilita mappa';
|
String get disableMap => 'Disabilita mappa';
|
||||||
|
|
||||||
@@ -149,75 +126,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get chooseLanguage => 'Scegli Lingua';
|
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
|
@override
|
||||||
String get save => 'Salva';
|
String get save => 'Salva';
|
||||||
|
|
||||||
@@ -253,12 +161,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get moreInfo => 'Maggiori informazioni';
|
String get moreInfo => 'Maggiori informazioni';
|
||||||
|
|
||||||
@override
|
|
||||||
String get learnMoreAbout => 'Ulteriori informazioni su MeshCore SAR';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get developer => 'Sviluppatore';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get packageName => 'Nome Pacchetto';
|
String get packageName => 'Nome Pacchetto';
|
||||||
|
|
||||||
@@ -335,9 +237,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get refresh => 'Aggiorna';
|
String get refresh => 'Aggiorna';
|
||||||
|
|
||||||
@override
|
|
||||||
String get sendDirectMessage => 'Invia';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get resetPath => 'Resetta Percorso (Ri-instrada)';
|
String get resetPath => 'Resetta Percorso (Ri-instrada)';
|
||||||
|
|
||||||
@@ -458,21 +357,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
return 'Condividi $count disegno$plural';
|
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
|
@override
|
||||||
String get showReceivedDrawings => 'Mostra Disegni Ricevuti';
|
String get showReceivedDrawings => 'Mostra Disegni Ricevuti';
|
||||||
|
|
||||||
@@ -494,9 +378,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearAll => 'Cancella Tutto';
|
String get clearAll => 'Cancella Tutto';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noLocalDrawings => 'Nessun disegno locale da condividere';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicChannel => 'Canale Pubblico';
|
String get publicChannel => 'Canale Pubblico';
|
||||||
|
|
||||||
@@ -506,37 +387,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storedPermanently => 'Archiviato permanentemente nella stanza';
|
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
|
@override
|
||||||
String get notConnectedToDevice => 'Non connesso al dispositivo';
|
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
|
@override
|
||||||
String get typeYourMessage => 'Scrivi il tuo messaggio...';
|
String get typeYourMessage => 'Scrivi il tuo messaggio...';
|
||||||
|
|
||||||
@@ -566,9 +419,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get location => 'Posizione';
|
String get location => 'Posizione';
|
||||||
|
|
||||||
@override
|
|
||||||
String get myLocation => 'La mia posizione';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fromMap => 'Dalla Mappa';
|
String get fromMap => 'Dalla Mappa';
|
||||||
|
|
||||||
@@ -630,9 +480,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get flood => 'Flood';
|
String get flood => 'Flood';
|
||||||
|
|
||||||
@override
|
|
||||||
String get admin => 'Admin';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loggedIn => 'Connesso';
|
String get loggedIn => 'Connesso';
|
||||||
|
|
||||||
@@ -642,29 +489,11 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get distance => 'Distanza';
|
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
|
@override
|
||||||
String directPingTimeout(String name) {
|
String directPingTimeout(String name) {
|
||||||
return 'Timeout ping diretto - nuovo tentativo $name con flooding...';
|
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
|
@override
|
||||||
String pingFailed(String name) {
|
String pingFailed(String name) {
|
||||||
return 'Ping fallito a $name - nessuna risposta ricevuta';
|
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.';
|
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
|
@override
|
||||||
String failedToRemoveContact(String error) {
|
String failedToRemoveContact(String error) {
|
||||||
return 'Impossibile rimuovere il contatto: $error';
|
return 'Impossibile rimuovere il contatto: $error';
|
||||||
@@ -729,11 +548,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get telemetry => 'Telemetria';
|
String get telemetry => 'Telemetria';
|
||||||
|
|
||||||
@override
|
|
||||||
String requestingTelemetry(String name) {
|
|
||||||
return 'Richiesta telemetria da $name...';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get voltage => 'Tensione';
|
String get voltage => 'Tensione';
|
||||||
|
|
||||||
@@ -805,9 +619,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get showAll => 'Mostra Tutto';
|
String get showAll => 'Mostra Tutto';
|
||||||
|
|
||||||
@override
|
|
||||||
String get nearbyContacts => 'Contatti Vicini';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationUnavailable => 'Posizione non disponibile';
|
String get locationUnavailable => 'Posizione non disponibile';
|
||||||
|
|
||||||
@@ -848,176 +659,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get channels => 'Canali';
|
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
|
@override
|
||||||
String get selectMapLayer => 'Seleziona Livello Mappa';
|
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
|
@override
|
||||||
String get openStreetMap => 'OpenStreetMap';
|
String get openStreetMap => 'OpenStreetMap';
|
||||||
|
|
||||||
@@ -1036,12 +680,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get googleTerrain => 'Google Terreno';
|
String get googleTerrain => 'Google Terreno';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadVisibleArea => 'Scarica area visibile';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get initializingMap => 'Inizializzazione mappa...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dragToPosition => 'Trascina per Posizionare';
|
String get dragToPosition => 'Trascina per Posizionare';
|
||||||
|
|
||||||
@@ -1057,52 +695,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarAlert => 'ALLERTA SAR';
|
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
|
@override
|
||||||
String get textCopiedToClipboard => 'Testo copiato negli appunti';
|
String get textCopiedToClipboard => 'Testo copiato negli appunti';
|
||||||
|
|
||||||
@@ -1114,9 +706,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String get cannotReplyContactNotFound =>
|
String get cannotReplyContactNotFound =>
|
||||||
'Impossibile rispondere: contatto non trovato';
|
'Impossibile rispondere: contatto non trovato';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messageDeleted => 'Messaggio eliminato';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get copyText => 'Copia testo';
|
String get copyText => 'Copia testo';
|
||||||
|
|
||||||
@@ -1152,12 +741,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarLocationShare => 'Posizione SAR';
|
String get sarLocationShare => 'Posizione SAR';
|
||||||
|
|
||||||
@override
|
|
||||||
String get locationShared => 'Posizione condivisa';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get refreshedContacts => 'Contatti aggiornati';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get justNow => 'Proprio ora';
|
String get justNow => 'Proprio ora';
|
||||||
|
|
||||||
@@ -1265,124 +848,22 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get you => 'Tu';
|
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
|
@override
|
||||||
String exportFailed(String error) {
|
String exportFailed(String error) {
|
||||||
return 'Export failed: $error';
|
return 'Esportazione non riuscita: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String importFailed(String error) {
|
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
|
@override
|
||||||
String get unknown => 'Sconosciuto';
|
String get unknown => 'Sconosciuto';
|
||||||
|
|
||||||
@override
|
|
||||||
String get bounds => 'Limiti';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onlineLayers => 'Livelli Online';
|
String get onlineLayers => 'Livelli Online';
|
||||||
|
|
||||||
@override
|
|
||||||
String get offlineLayers => 'Livelli Offline';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationTrail => 'Traccia Posizione';
|
String get locationTrail => 'Traccia Posizione';
|
||||||
|
|
||||||
@@ -1422,47 +903,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trailControls => 'Controlli Traccia';
|
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
|
@override
|
||||||
String get contactTrails => 'Tracce contatti';
|
String get contactTrails => 'Tracce contatti';
|
||||||
|
|
||||||
@@ -1584,7 +1024,7 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String get templateName => 'Nome modello';
|
String get templateName => 'Nome modello';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateNameHint => 'e.g. Found Person';
|
String get templateNameHint => 'ad es. Persona trovata';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateEmoji => 'Emoji';
|
String get templateEmoji => 'Emoji';
|
||||||
@@ -1596,16 +1036,16 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String get nameRequired => 'Nome è obbligatorio';
|
String get nameRequired => 'Nome è obbligatorio';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDescription => 'Description (Optional)';
|
String get templateDescription => 'Descrizione (facoltativa)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDescriptionHint => 'Add additional context...';
|
String get templateDescriptionHint => 'Aggiungi contesto aggiuntivo...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateColor => 'Color';
|
String get templateColor => 'Colore';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get previewFormat => 'Preview (SAR Message Format)';
|
String get previewFormat => 'Anteprima (formato messaggio SAR)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get importFromClipboard => 'Importa';
|
String get importFromClipboard => 'Importa';
|
||||||
@@ -1615,26 +1055,26 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String deleteTemplateConfirmation(String name) {
|
String deleteTemplateConfirmation(String name) {
|
||||||
return 'Delete template \'$name\'?';
|
return 'Eliminare il modello \'$name\'?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateAdded => 'Template added';
|
String get templateAdded => 'Modello aggiunto';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateUpdated => 'Template updated';
|
String get templateUpdated => 'Modello aggiornato';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get templateDeleted => 'Template deleted';
|
String get templateDeleted => 'Modello eliminato';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String templatesImported(int count) {
|
String templatesImported(int count) {
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'Imported $count templates',
|
other: 'Importati $count modelli',
|
||||||
one: 'Imported 1 template',
|
one: 'Importato 1 modello',
|
||||||
zero: 'No templates imported',
|
zero: 'Nessun modello importato',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -1644,8 +1084,8 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: 'Exported $count templates to clipboard',
|
other: 'Esportati $count modelli negli appunti',
|
||||||
one: 'Exported 1 template to clipboard',
|
one: 'Esportato 1 modello negli appunti',
|
||||||
);
|
);
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
@@ -1664,10 +1104,10 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String get resetComplete => 'Modelli ripristinati ai valori predefiniti';
|
String get resetComplete => 'Modelli ripristinati ai valori predefiniti';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noTemplates => 'No templates available';
|
String get noTemplates => 'Nessun modello disponibile';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get tapAddToCreate => 'Tap + to create your first template';
|
String get tapAddToCreate => 'Tocca + per creare il tuo primo modello';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ok => 'OK';
|
String get ok => 'OK';
|
||||||
@@ -1734,20 +1174,12 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noRoomsFound => 'Nessuna stanza trovata';
|
String get noRoomsFound => 'Nessuna stanza trovata';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noContactsOrRoomsAvailable =>
|
|
||||||
'Nessun contatto o stanza disponibile';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noRecipientsAvailable => 'Nessun destinatario disponibile';
|
String get noRecipientsAvailable => 'Nessun destinatario disponibile';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noChannelsFound => 'Nessun canale trovato';
|
String get noChannelsFound => 'Nessun canale trovato';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messagesWillBeSentToPublicChannel =>
|
|
||||||
'I messaggi saranno inviati al canale pubblico';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newMessage => 'Nuovo messaggio';
|
String get newMessage => 'Nuovo messaggio';
|
||||||
|
|
||||||
@@ -1932,9 +1364,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rectangleDrawing => 'Rettangolo';
|
String get rectangleDrawing => 'Rettangolo';
|
||||||
|
|
||||||
@override
|
|
||||||
String get coordinatesCopiedToClipboard => 'Coordinate copiate negli appunti';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get manualCoordinates => 'Coordinate Manuali';
|
String get manualCoordinates => 'Coordinate Manuali';
|
||||||
|
|
||||||
@@ -1947,31 +1376,9 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get longitudeLabel => 'Longitudine';
|
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
|
@override
|
||||||
String get exampleCoordinates => 'Esempio: 46.0569, 14.5058';
|
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
|
@override
|
||||||
String get shareDrawing => 'Condividi Disegno';
|
String get shareDrawing => 'Condividi Disegno';
|
||||||
|
|
||||||
@@ -2028,12 +1435,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forestRoads => 'Strade Forestali';
|
String get forestRoads => 'Strade Forestali';
|
||||||
|
|
||||||
@override
|
|
||||||
String get showCadastralParcels => 'Mostra particelle catastali';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get showForestRoads => 'Mostra strade forestali';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get wmsOverlays => 'Sovrapposizioni WMS';
|
String get wmsOverlays => 'Sovrapposizioni WMS';
|
||||||
|
|
||||||
@@ -2138,10 +1539,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
return 'Eliminazione del canale fallita: $error';
|
return 'Eliminazione del canale fallita: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get allChannelSlotsInUse =>
|
|
||||||
'Tutti gli slot dei canali sono in uso (massimo 39 canali personalizzati)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get createChannel => 'Crea Canale';
|
String get createChannel => 'Crea Canale';
|
||||||
|
|
||||||
@@ -2183,21 +1580,6 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String get wizardConnectingFeature3 =>
|
String get wizardConnectingFeature3 =>
|
||||||
'Funziona completamente offline - non è richiesta connessione internet';
|
'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
|
@override
|
||||||
String get wizardChannelTitle => 'Canali';
|
String get wizardChannelTitle => 'Canali';
|
||||||
|
|
||||||
|
|||||||
1732
lib/l10n/app_localizations_pl.dart
Normal file
1732
lib/l10n/app_localizations_pl.dart
Normal file
File diff suppressed because it is too large
Load Diff
1739
lib/l10n/app_localizations_pt.dart
Normal file
1739
lib/l10n/app_localizations_pt.dart
Normal file
File diff suppressed because it is too large
Load Diff
1729
lib/l10n/app_localizations_ru.dart
Normal file
1729
lib/l10n/app_localizations_ru.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -29,9 +29,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get disconnect => 'Prekini';
|
String get disconnect => 'Prekini';
|
||||||
|
|
||||||
@override
|
|
||||||
String get scanningForDevices => 'Iskanje naprav...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noDevicesFound => 'Ni najdenih naprav';
|
String get noDevicesFound => 'Ni najdenih naprav';
|
||||||
|
|
||||||
@@ -62,27 +59,14 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get failedToGetGpsLocation => 'Pridobitev GPS lokacije ni uspela';
|
String get failedToGetGpsLocation => 'Pridobitev GPS lokacije ni uspela';
|
||||||
|
|
||||||
@override
|
|
||||||
String advertisedAtLocation(String latitude, String longitude) {
|
|
||||||
return 'Objavljeno na $latitude, $longitude';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String failedToAdvertise(String error) {
|
String failedToAdvertise(String error) {
|
||||||
return 'Objava ni uspela: $error';
|
return 'Objava ni uspela: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String reconnecting(int attempt, int max) {
|
|
||||||
return 'Ponovno povezovanje... ($attempt/$max)';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get cancelReconnection => 'Prekliči ponovno povezovanje';
|
String get cancelReconnection => 'Prekliči ponovno povezovanje';
|
||||||
|
|
||||||
@override
|
|
||||||
String get mapManagement => 'Upravljanje zemljevida';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get general => 'Splošno';
|
String get general => 'Splošno';
|
||||||
|
|
||||||
@@ -129,13 +113,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
String get displayPacketActivity =>
|
String get displayPacketActivity =>
|
||||||
'Prikaži kazalnike aktivnosti paketov v zgornji vrstici';
|
'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
|
@override
|
||||||
String get disableMap => 'Onemogoči zemljevid';
|
String get disableMap => 'Onemogoči zemljevid';
|
||||||
|
|
||||||
@@ -149,74 +126,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get chooseLanguage => 'Izberite jezik';
|
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
|
@override
|
||||||
String get save => 'Shrani';
|
String get save => 'Shrani';
|
||||||
|
|
||||||
@@ -252,12 +161,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get moreInfo => 'Več informacij';
|
String get moreInfo => 'Več informacij';
|
||||||
|
|
||||||
@override
|
|
||||||
String get learnMoreAbout => 'Več o MeshCore SAR';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get developer => 'Razvijalec';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get packageName => 'Ime paketa';
|
String get packageName => 'Ime paketa';
|
||||||
|
|
||||||
@@ -333,9 +236,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get refresh => 'Osveži';
|
String get refresh => 'Osveži';
|
||||||
|
|
||||||
@override
|
|
||||||
String get sendDirectMessage => 'Pošlji';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get resetPath => 'Ponastavi pot (preusmeri)';
|
String get resetPath => 'Ponastavi pot (preusmeri)';
|
||||||
|
|
||||||
@@ -454,21 +354,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
return 'Deli $count risb$plural';
|
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
|
@override
|
||||||
String get showReceivedDrawings => 'Prikaži prejete risbe';
|
String get showReceivedDrawings => 'Prikaži prejete risbe';
|
||||||
|
|
||||||
@@ -490,9 +375,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearAll => 'Počisti vse';
|
String get clearAll => 'Počisti vse';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noLocalDrawings => 'Ni lokalnih risb za deljenje';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get publicChannel => 'Javni kanal';
|
String get publicChannel => 'Javni kanal';
|
||||||
|
|
||||||
@@ -502,37 +384,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get storedPermanently => 'Trajno shranjeno v sobi';
|
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
|
@override
|
||||||
String get notConnectedToDevice => 'Ni povezano z napravo';
|
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
|
@override
|
||||||
String get typeYourMessage => 'Vnesite svoje sporočilo...';
|
String get typeYourMessage => 'Vnesite svoje sporočilo...';
|
||||||
|
|
||||||
@@ -562,9 +416,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get location => 'Lokacija';
|
String get location => 'Lokacija';
|
||||||
|
|
||||||
@override
|
|
||||||
String get myLocation => 'Moja lokacija';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get fromMap => 'Z zemljevida';
|
String get fromMap => 'Z zemljevida';
|
||||||
|
|
||||||
@@ -626,9 +477,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get flood => 'Razpršitev';
|
String get flood => 'Razpršitev';
|
||||||
|
|
||||||
@override
|
|
||||||
String get admin => 'Administrator';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get loggedIn => 'Prijavljen';
|
String get loggedIn => 'Prijavljen';
|
||||||
|
|
||||||
@@ -638,29 +486,11 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get distance => 'Razdalja';
|
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
|
@override
|
||||||
String directPingTimeout(String name) {
|
String directPingTimeout(String name) {
|
||||||
return 'Časovna omejitev neposrednega pinga - ponovni poskus $name z razprševanjem...';
|
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
|
@override
|
||||||
String pingFailed(String name) {
|
String pingFailed(String name) {
|
||||||
return 'Ping neuspešen do $name - odgovor ni prejet';
|
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.';
|
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
|
@override
|
||||||
String failedToRemoveContact(String error) {
|
String failedToRemoveContact(String error) {
|
||||||
return 'Odstranjevanje stika ni uspelo: $error';
|
return 'Odstranjevanje stika ni uspelo: $error';
|
||||||
@@ -725,11 +545,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get telemetry => 'Telemetrija';
|
String get telemetry => 'Telemetrija';
|
||||||
|
|
||||||
@override
|
|
||||||
String requestingTelemetry(String name) {
|
|
||||||
return 'Zahtevanje telemetrije od $name...';
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get voltage => 'Napetost';
|
String get voltage => 'Napetost';
|
||||||
|
|
||||||
@@ -801,9 +616,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get showAll => 'Prikaži vse';
|
String get showAll => 'Prikaži vse';
|
||||||
|
|
||||||
@override
|
|
||||||
String get nearbyContacts => 'Bližnji stiki';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationUnavailable => 'Lokacija ni na voljo';
|
String get locationUnavailable => 'Lokacija ni na voljo';
|
||||||
|
|
||||||
@@ -844,174 +656,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get channels => 'Kanali';
|
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
|
@override
|
||||||
String get selectMapLayer => 'Izberite sloj zemljevida';
|
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
|
@override
|
||||||
String get openStreetMap => 'OpenStreetMap';
|
String get openStreetMap => 'OpenStreetMap';
|
||||||
|
|
||||||
@@ -1030,12 +677,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get googleTerrain => 'Google teren';
|
String get googleTerrain => 'Google teren';
|
||||||
|
|
||||||
@override
|
|
||||||
String get downloadVisibleArea => 'Prenesi vidno območje';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get initializingMap => 'Inicializacija zemljevida...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get dragToPosition => 'Povleci na položaj';
|
String get dragToPosition => 'Povleci na položaj';
|
||||||
|
|
||||||
@@ -1051,51 +692,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarAlert => 'SAR ALARM';
|
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
|
@override
|
||||||
String get textCopiedToClipboard => 'Besedilo kopirano v odložišče';
|
String get textCopiedToClipboard => 'Besedilo kopirano v odložišče';
|
||||||
|
|
||||||
@@ -1107,9 +703,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
String get cannotReplyContactNotFound =>
|
String get cannotReplyContactNotFound =>
|
||||||
'Ni mogoče odgovoriti: stik ni najden';
|
'Ni mogoče odgovoriti: stik ni najden';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messageDeleted => 'Sporočilo izbrisano';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get copyText => 'Kopiraj besedilo';
|
String get copyText => 'Kopiraj besedilo';
|
||||||
|
|
||||||
@@ -1145,12 +738,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get sarLocationShare => 'SAR Lokacija';
|
String get sarLocationShare => 'SAR Lokacija';
|
||||||
|
|
||||||
@override
|
|
||||||
String get locationShared => 'Lokacija deljena';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get refreshedContacts => 'Stiki osveženi';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get justNow => 'Pravkar';
|
String get justNow => 'Pravkar';
|
||||||
|
|
||||||
@@ -1258,81 +845,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get you => 'Ti';
|
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
|
@override
|
||||||
String exportFailed(String error) {
|
String exportFailed(String error) {
|
||||||
return 'Izvoz ni uspel: $error';
|
return 'Izvoz ni uspel: $error';
|
||||||
@@ -1343,40 +855,12 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
return 'Uvoz ni uspel: $error';
|
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
|
@override
|
||||||
String get unknown => 'Neznano';
|
String get unknown => 'Neznano';
|
||||||
|
|
||||||
@override
|
|
||||||
String get bounds => 'Meje';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onlineLayers => 'Spletne plasti';
|
String get onlineLayers => 'Spletne plasti';
|
||||||
|
|
||||||
@override
|
|
||||||
String get offlineLayers => 'Brezpovezne plasti';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get locationTrail => 'Sledilna pot';
|
String get locationTrail => 'Sledilna pot';
|
||||||
|
|
||||||
@@ -1415,47 +899,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get trailControls => 'Nadzor poti';
|
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
|
@override
|
||||||
String get contactTrails => 'Poti stikov';
|
String get contactTrails => 'Poti stikov';
|
||||||
|
|
||||||
@@ -1661,7 +1104,7 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
String get tapAddToCreate => 'Tapnite + za ustvarjanje prve predloge';
|
String get tapAddToCreate => 'Tapnite + za ustvarjanje prve predloge';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get ok => 'OK';
|
String get ok => 'V redu';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get permissionsSection => 'Dovoljenja';
|
String get permissionsSection => 'Dovoljenja';
|
||||||
@@ -1725,19 +1168,12 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get noRoomsFound => 'Ni sob';
|
String get noRoomsFound => 'Ni sob';
|
||||||
|
|
||||||
@override
|
|
||||||
String get noContactsOrRoomsAvailable => 'Ni na voljo kontaktov ali sob';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noRecipientsAvailable => 'Ni na voljo prejemnikov';
|
String get noRecipientsAvailable => 'Ni na voljo prejemnikov';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get noChannelsFound => 'Ni najdenih kanalov';
|
String get noChannelsFound => 'Ni najdenih kanalov';
|
||||||
|
|
||||||
@override
|
|
||||||
String get messagesWillBeSentToPublicChannel =>
|
|
||||||
'Sporočila bodo poslana na javni kanal';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get newMessage => 'Novo sporočilo';
|
String get newMessage => 'Novo sporočilo';
|
||||||
|
|
||||||
@@ -1918,9 +1354,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get rectangleDrawing => 'Pravokotna risba';
|
String get rectangleDrawing => 'Pravokotna risba';
|
||||||
|
|
||||||
@override
|
|
||||||
String get coordinatesCopiedToClipboard => 'Koordinate kopirane v odložišče';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get manualCoordinates => 'Ročne koordinate';
|
String get manualCoordinates => 'Ročne koordinate';
|
||||||
|
|
||||||
@@ -1933,37 +1366,9 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get longitudeLabel => 'Geografska dolžina';
|
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
|
@override
|
||||||
String get exampleCoordinates => 'Primer: 46.0569, 14.5058';
|
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
|
@override
|
||||||
String get shareDrawing => 'Deli risbo';
|
String get shareDrawing => 'Deli risbo';
|
||||||
|
|
||||||
@@ -2018,12 +1423,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get forestRoads => 'Gozdne ceste';
|
String get forestRoads => 'Gozdne ceste';
|
||||||
|
|
||||||
@override
|
|
||||||
String get showCadastralParcels => 'Prikaži katastrske parcele';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get showForestRoads => 'Prikaži gozdne ceste';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get wmsOverlays => 'WMS prekrivanja';
|
String get wmsOverlays => 'WMS prekrivanja';
|
||||||
|
|
||||||
@@ -2126,10 +1525,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
return 'Neuspešno brisanje kanala: $error';
|
return 'Neuspešno brisanje kanala: $error';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get allChannelSlotsInUse =>
|
|
||||||
'Vsa mesta za kanale so zasedena (maksimalno 39 prilagojenih kanalov)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get createChannel => 'Ustvari kanal';
|
String get createChannel => 'Ustvari kanal';
|
||||||
|
|
||||||
@@ -2170,21 +1565,6 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
String get wizardConnectingFeature3 =>
|
String get wizardConnectingFeature3 =>
|
||||||
'Deluje povsem brez povezave — internet ni potreben';
|
'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
|
@override
|
||||||
String get wizardChannelTitle => 'Kanali';
|
String get wizardChannelTitle => 'Kanali';
|
||||||
|
|
||||||
@@ -2279,7 +1659,7 @@ class AppLocalizationsSl extends AppLocalizations {
|
|||||||
String get delivery => 'Dostava';
|
String get delivery => 'Dostava';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get status => 'Status';
|
String get status => 'Stanje';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get expectedAckTag => 'Pričakovana oznaka ACK';
|
String get expectedAckTag => 'Pričakovana oznaka ACK';
|
||||||
|
|||||||
1723
lib/l10n/app_localizations_tr.dart
Normal file
1723
lib/l10n/app_localizations_tr.dart
Normal file
File diff suppressed because it is too large
Load Diff
1735
lib/l10n/app_localizations_uk.dart
Normal file
1735
lib/l10n/app_localizations_uk.dart
Normal file
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
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
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
515
lib/l10n/app_ru.arb
Normal 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
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
2280
lib/l10n/app_uk.arb
Normal file
File diff suppressed because it is too large
Load Diff
2341
lib/l10n/app_zh.arb
2341
lib/l10n/app_zh.arb
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -14,11 +17,12 @@ import 'providers/channels_provider.dart';
|
|||||||
import 'providers/voice_provider.dart';
|
import 'providers/voice_provider.dart';
|
||||||
import 'providers/image_provider.dart' as ip;
|
import 'providers/image_provider.dart' as ip;
|
||||||
import 'providers/app_provider.dart';
|
import 'providers/app_provider.dart';
|
||||||
|
import 'providers/sensors_provider.dart';
|
||||||
import 'services/voice_codec_service.dart';
|
import 'services/voice_codec_service.dart';
|
||||||
import 'services/voice_player_service.dart';
|
import 'services/voice_player_service.dart';
|
||||||
import 'services/tile_cache_service.dart';
|
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'services/locale_preferences.dart';
|
import 'services/locale_preferences.dart';
|
||||||
|
import 'services/mesh_map_nodes_service.dart';
|
||||||
import 'services/update_checker_service.dart';
|
import 'services/update_checker_service.dart';
|
||||||
import 'services/wizard_preferences.dart';
|
import 'services/wizard_preferences.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
@@ -70,6 +74,9 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
// Shows notification if update is available
|
// Shows notification if update is available
|
||||||
_checkForUpdates();
|
_checkForUpdates();
|
||||||
|
|
||||||
|
// Refresh remote mesh node cache in background when the app starts.
|
||||||
|
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_wizardCompleted = wizardCompleted;
|
_wizardCompleted = wizardCompleted;
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
@@ -158,7 +165,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
/// Shows notification if update is available
|
/// Shows notification if update is available
|
||||||
Future<void> _checkForUpdates() async {
|
Future<void> _checkForUpdates() async {
|
||||||
// Only check for updates on Android
|
// Only check for updates on Android
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -234,6 +241,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||||
|
|
||||||
// Voice provider (packet reassembly + playback)
|
// Voice provider (packet reassembly + playback)
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
@@ -246,18 +254,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
// Image provider (fragment reassembly + outgoing session cache)
|
// Image provider (fragment reassembly + outgoing session cache)
|
||||||
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
|
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
|
||||||
|
|
||||||
// Tile cache service
|
|
||||||
Provider(create: (_) => TileCacheService()),
|
|
||||||
|
|
||||||
// App provider that coordinates everything
|
// App provider that coordinates everything
|
||||||
// VoiceProvider is read via context.read inside create since it's already registered above
|
// VoiceProvider is read via context.read inside create since it's already registered above
|
||||||
ChangeNotifierProxyProvider6<
|
ChangeNotifierProxyProvider5<
|
||||||
ConnectionProvider,
|
ConnectionProvider,
|
||||||
ContactsProvider,
|
ContactsProvider,
|
||||||
MessagesProvider,
|
MessagesProvider,
|
||||||
DrawingProvider,
|
DrawingProvider,
|
||||||
ChannelsProvider,
|
ChannelsProvider,
|
||||||
TileCacheService,
|
|
||||||
AppProvider
|
AppProvider
|
||||||
>(
|
>(
|
||||||
create: (context) => AppProvider(
|
create: (context) => AppProvider(
|
||||||
@@ -268,7 +272,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
channelsProvider: context.read<ChannelsProvider>(),
|
channelsProvider: context.read<ChannelsProvider>(),
|
||||||
voiceProvider: context.read<VoiceProvider>(),
|
voiceProvider: context.read<VoiceProvider>(),
|
||||||
imageProvider: context.read<ip.ImageProvider>(),
|
imageProvider: context.read<ip.ImageProvider>(),
|
||||||
tileCacheService: context.read<TileCacheService>(),
|
|
||||||
),
|
),
|
||||||
update:
|
update:
|
||||||
(
|
(
|
||||||
@@ -278,7 +281,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
messages,
|
messages,
|
||||||
drawings,
|
drawings,
|
||||||
channels,
|
channels,
|
||||||
tileCache,
|
|
||||||
previous,
|
previous,
|
||||||
) =>
|
) =>
|
||||||
previous ??
|
previous ??
|
||||||
@@ -290,7 +292,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
channelsProvider: channels,
|
channelsProvider: channels,
|
||||||
voiceProvider: context.read<VoiceProvider>(),
|
voiceProvider: context.read<VoiceProvider>(),
|
||||||
imageProvider: context.read<ip.ImageProvider>(),
|
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)
|
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
|
||||||
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
|
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
|
||||||
if (Platform.isAndroid) {
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
left: false,
|
left: false,
|
||||||
right: false,
|
right: false,
|
||||||
|
|||||||
@@ -1,10 +1,186 @@
|
|||||||
export 'package:meshcore_client/meshcore_client.dart'
|
export 'package:meshcore_client/meshcore_client.dart'
|
||||||
show Contact, ContactType, ContactTelemetry, AdvertLocation;
|
show Contact, ContactType, ContactTelemetry, AdvertLocation;
|
||||||
|
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'dart:typed_data';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'package:meshcore_client/meshcore_client.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 {
|
extension ContactLocalization on Contact {
|
||||||
/// Returns the localized display name for special contacts (e.g. Public Channel).
|
/// Returns the localized display name for special contacts (e.g. Public Channel).
|
||||||
/// For all other contacts, returns [displayName].
|
/// For all other contacts, returns [displayName].
|
||||||
@@ -14,4 +190,55 @@ extension ContactLocalization on Contact {
|
|||||||
}
|
}
|
||||||
return displayName;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
@@ -10,6 +9,7 @@ enum MapLayerType {
|
|||||||
googleHybrid,
|
googleHybrid,
|
||||||
googleRoadmap,
|
googleRoadmap,
|
||||||
googleTerrain,
|
googleTerrain,
|
||||||
|
// Kept for stored preference compatibility after MBTiles removal.
|
||||||
vectorMbtiles,
|
vectorMbtiles,
|
||||||
wmsBase,
|
wmsBase,
|
||||||
}
|
}
|
||||||
@@ -21,13 +21,6 @@ class MapLayer {
|
|||||||
final String attribution;
|
final String attribution;
|
||||||
final double maxZoom;
|
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
|
// WMS specific properties
|
||||||
final bool isWms;
|
final bool isWms;
|
||||||
final String? wmsBaseUrl;
|
final String? wmsBaseUrl;
|
||||||
@@ -43,11 +36,6 @@ class MapLayer {
|
|||||||
required this.urlTemplate,
|
required this.urlTemplate,
|
||||||
required this.attribution,
|
required this.attribution,
|
||||||
required this.maxZoom,
|
required this.maxZoom,
|
||||||
this.isVector = false,
|
|
||||||
this.mbtilesFile,
|
|
||||||
this.styleUrl,
|
|
||||||
this.sourceName,
|
|
||||||
this.isGzipped,
|
|
||||||
this.isWms = false,
|
this.isWms = false,
|
||||||
this.wmsBaseUrl,
|
this.wmsBaseUrl,
|
||||||
this.wmsLayers,
|
this.wmsLayers,
|
||||||
@@ -74,7 +62,7 @@ class MapLayer {
|
|||||||
case MapLayerType.googleTerrain:
|
case MapLayerType.googleTerrain:
|
||||||
return localizations.googleTerrain;
|
return localizations.googleTerrain;
|
||||||
case MapLayerType.vectorMbtiles:
|
case MapLayerType.vectorMbtiles:
|
||||||
// For vector tiles, use the name from metadata
|
// Legacy value kept only for preference migration compatibility.
|
||||||
return name;
|
return name;
|
||||||
case MapLayerType.wmsBase:
|
case MapLayerType.wmsBase:
|
||||||
// For WMS layers, use the name (will be localized separately)
|
// For WMS layers, use the name (will be localized separately)
|
||||||
@@ -182,28 +170,4 @@ class MapLayer {
|
|||||||
static MapLayer fromType(MapLayerType type) {
|
static MapLayer fromType(MapLayerType type) {
|
||||||
return allLayers.firstWhere((layer) => layer.type == 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
79
lib/models/message_route_metadata.dart
Normal file
79
lib/models/message_route_metadata.dart
Normal 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
lib/models/message_transfer_details.dart
Normal file
142
lib/models/message_transfer_details.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
lib/models/path_history.dart
Normal file
182
lib/models/path_history.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/models/path_selection.dart
Normal file
65
lib/models/path_selection.dart
Normal 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
@@ -4,10 +4,11 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
import '../models/device_info.dart';
|
import '../models/device_info.dart';
|
||||||
import '../models/room_login_state.dart';
|
import '../models/room_login_state.dart';
|
||||||
import '../models/sse_server_config.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 '../services/sse_server_service.dart';
|
||||||
import '../utils/sar_message_parser.dart';
|
import '../utils/sar_message_parser.dart';
|
||||||
import 'helpers/room_login_manager.dart';
|
import 'helpers/room_login_manager.dart';
|
||||||
@@ -174,6 +175,12 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
|
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
|
||||||
onMessageSent;
|
onMessageSent;
|
||||||
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
|
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)?
|
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
|
||||||
onMessageEchoDetected;
|
onMessageEchoDetected;
|
||||||
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
|
||||||
@@ -479,6 +486,14 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
/// Start scanning for MeshCore devices
|
/// Start scanning for MeshCore devices
|
||||||
Future<void> startScan() async {
|
Future<void> startScan() async {
|
||||||
debugPrint('🔍 [Provider] startScan() called');
|
debugPrint('🔍 [Provider] startScan() called');
|
||||||
|
if (_deviceInfo.connectionState == ConnectionState.connecting ||
|
||||||
|
_deviceInfo.connectionState == ConnectionState.connected) {
|
||||||
|
debugPrint(
|
||||||
|
'⏭️ [Provider] Ignoring scan request while connection is active: ${_deviceInfo.connectionState}',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_isScanning = true;
|
_isScanning = true;
|
||||||
_scannedDevices.clear();
|
_scannedDevices.clear();
|
||||||
_error = null;
|
_error = null;
|
||||||
@@ -552,6 +567,11 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
'🔵 [Provider] connect() called for device: ${device.platformName}',
|
'🔵 [Provider] connect() called for device: ${device.platformName}',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (_isScanning) {
|
||||||
|
debugPrint('🔵 [Provider] Stopping active scan before connect()');
|
||||||
|
await stopScan();
|
||||||
|
}
|
||||||
|
|
||||||
_deviceInfo = _deviceInfo.copyWith(
|
_deviceInfo = _deviceInfo.copyWith(
|
||||||
deviceId: device.remoteId.toString(),
|
deviceId: device.remoteId.toString(),
|
||||||
deviceName: device.platformName.isNotEmpty
|
deviceName: device.platformName.isNotEmpty
|
||||||
@@ -1072,7 +1092,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
///
|
///
|
||||||
/// [messageId] - optional message ID to track delivery status
|
/// [messageId] - optional message ID to track delivery status
|
||||||
/// [contact] - optional contact object for path status logging
|
/// [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({
|
Future<bool> sendTextMessage({
|
||||||
required Uint8List contactPublicKey,
|
required Uint8List contactPublicKey,
|
||||||
required String text,
|
required String text,
|
||||||
@@ -1086,6 +1106,17 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
return false;
|
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)
|
// CRITICAL: Check firmware ACK limit (8 max in circular buffer)
|
||||||
// Rate limit at 7 to stay under the limit
|
// Rate limit at 7 to stay under the limit
|
||||||
if (_messageDeliveryTracker.shouldRateLimit) {
|
if (_messageDeliveryTracker.shouldRateLimit) {
|
||||||
@@ -1110,31 +1141,33 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Log path status and retry info
|
// Log path status and retry info
|
||||||
if (contact != null) {
|
if (effectiveContact != null) {
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)',
|
'🔄 [ConnectionProvider] Sending message to ${effectiveContact.advName} (retry $retryAttempt)',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📤 [ConnectionProvider] Sending message to ${contact.advName}',
|
'📤 [ConnectionProvider] Sending message to ${effectiveContact.advName}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
debugPrint(' Type: ${contact.type.displayName}');
|
debugPrint(' Type: ${effectiveContact.type.displayName}');
|
||||||
debugPrint(' Path status: ${contact.pathDescription}');
|
debugPrint(' Path status: ${effectiveContact.routeSummary}');
|
||||||
if (contact.hasPath) {
|
if (effectiveContact.routeHasPath) {
|
||||||
debugPrint(' ✅ Using learned path (${contact.outPathLen} bytes)');
|
debugPrint(
|
||||||
|
' ✅ Using learned path (${effectiveContact.routeHopCount} hop(s), ${effectiveContact.routeHashSize}-byte hashes)',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
debugPrint(' ⚠️ No path available - will use flood mode');
|
debugPrint(' ⚠️ No path available - will use flood mode');
|
||||||
}
|
}
|
||||||
} else if (retryAttempt > 0) {
|
} else if (retryAttempt > 0) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)',
|
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt)',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track pending operation for auto-recovery (if contact not found in radio)
|
// Track pending operation for auto-recovery (if contact not found in radio)
|
||||||
if (contact != null) {
|
if (effectiveContact != null) {
|
||||||
final operationId = contactPublicKey
|
final operationId = contactPublicKey
|
||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
@@ -1143,7 +1176,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
contactPublicKey: contactPublicKey,
|
contactPublicKey: contactPublicKey,
|
||||||
text: text,
|
text: text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: effectiveContact,
|
||||||
retryAttempt: retryAttempt,
|
retryAttempt: retryAttempt,
|
||||||
);
|
);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1173,9 +1206,22 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
attempt: retryAttempt,
|
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)
|
// Clear pending operation after successful send (no error)
|
||||||
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
|
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
|
||||||
if (contact != null) {
|
if (effectiveContact != null) {
|
||||||
final operationId = contactPublicKey
|
final operationId = contactPublicKey
|
||||||
.sublist(0, 6)
|
.sublist(0, 6)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
@@ -1274,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
|
/// Request telemetry from contact
|
||||||
///
|
///
|
||||||
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
|
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
|
||||||
@@ -1999,6 +2058,7 @@ class ConnectionProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
_error = null;
|
||||||
await _activeService.resetPath(contactPublicKey);
|
await _activeService.resetPath(contactPublicKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_error = 'Failed to reset path: $e';
|
_error = 'Failed to reset path: $e';
|
||||||
@@ -2006,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
|
/// Remove a contact from the companion radio
|
||||||
///
|
///
|
||||||
/// Deletes the contact from the device's internal contact table.
|
/// Deletes the contact from the device's internal contact table.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../models/contact.dart';
|
|||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../services/cayenne_lpp_parser.dart';
|
import '../services/cayenne_lpp_parser.dart';
|
||||||
import '../services/contact_storage_service.dart';
|
import '../services/contact_storage_service.dart';
|
||||||
|
import '../utils/fast_gps_packet.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
|
|
||||||
class PendingAdvert {
|
class PendingAdvert {
|
||||||
@@ -282,45 +283,16 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a new contact
|
// Check if this is a new contact
|
||||||
final isNewContact = !_contacts.containsKey(contact.publicKeyHex);
|
final existingContact = _contacts[contact.publicKeyHex];
|
||||||
|
final isNewContact = existingContact == null;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
|
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
|
||||||
);
|
);
|
||||||
|
|
||||||
Contact updatedContact;
|
final updatedContact = _mergeIncomingContact(
|
||||||
if (isNewContact) {
|
incomingContact: contact,
|
||||||
// New contact - add initial location to history if available
|
existingContact: existingContact,
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_contacts[contact.publicKeyHex] = updatedContact;
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
_pendingAdverts.remove(contact.publicKeyHex);
|
_pendingAdverts.remove(contact.publicKeyHex);
|
||||||
@@ -346,7 +318,11 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
excluded++;
|
excluded++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
_contacts[contact.publicKeyHex] = contact;
|
final existingContact = _contacts[contact.publicKeyHex];
|
||||||
|
_contacts[contact.publicKeyHex] = _mergeIncomingContact(
|
||||||
|
incomingContact: contact,
|
||||||
|
existingContact: existingContact,
|
||||||
|
);
|
||||||
_pendingAdverts.remove(contact.publicKeyHex);
|
_pendingAdverts.remove(contact.publicKeyHex);
|
||||||
}
|
}
|
||||||
if (excluded > 0) {
|
if (excluded > 0) {
|
||||||
@@ -358,6 +334,60 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
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
|
/// Update contact telemetry
|
||||||
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
void updateTelemetry(Uint8List publicKeyPrefix, Uint8List lppData) {
|
||||||
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
debugPrint('📊 [ContactsProvider] updateTelemetry() called');
|
||||||
@@ -397,23 +427,22 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep last valid GPS for router/chat/room contacts when current
|
final previousTelemetry = contact.telemetry;
|
||||||
// telemetry does not provide a valid GPS fix.
|
|
||||||
if (_shouldRetainLastValidGps(contact, telemetry.gpsLocation)) {
|
final mergedTelemetry = _mergeTelemetryForContact(
|
||||||
debugPrint(
|
existingTelemetry: previousTelemetry,
|
||||||
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
|
incomingTelemetry: telemetry,
|
||||||
);
|
);
|
||||||
final previousGps = _getValidGpsOrNull(contact.telemetry?.gpsLocation);
|
if (mergedTelemetry != null) {
|
||||||
telemetry = ContactTelemetry(
|
if (_shouldRetainLastValidGps(
|
||||||
gpsLocation: previousGps,
|
previousTelemetry,
|
||||||
batteryPercentage: telemetry.batteryPercentage,
|
telemetry.gpsLocation,
|
||||||
batteryMilliVolts: telemetry.batteryMilliVolts,
|
)) {
|
||||||
temperature: telemetry.temperature,
|
debugPrint(
|
||||||
timestamp: telemetry.timestamp,
|
' ⚠️ Retaining last valid GPS. Incoming telemetry GPS is invalid/missing: $incomingGps',
|
||||||
humidity: telemetry.humidity,
|
);
|
||||||
pressure: telemetry.pressure,
|
}
|
||||||
extraSensorData: telemetry.extraSensorData,
|
telemetry = mergedTelemetry;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update contact with new telemetry AND last seen time
|
// Update contact with new telemetry AND last seen time
|
||||||
@@ -423,9 +452,16 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
debugPrint(' Old lastAdvert: ${contact.lastAdvert}');
|
debugPrint(' Old lastAdvert: ${contact.lastAdvert}');
|
||||||
debugPrint(' New lastAdvert: $currentTimestamp');
|
debugPrint(' New lastAdvert: $currentTimestamp');
|
||||||
|
|
||||||
|
final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation);
|
||||||
final updatedContact = contact.copyWith(
|
final updatedContact = contact.copyWith(
|
||||||
telemetry: telemetry,
|
telemetry: telemetry,
|
||||||
lastAdvert: currentTimestamp, // Update last seen time
|
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;
|
_contacts[contact.publicKeyHex] = updatedContact;
|
||||||
debugPrint(' ✅ Updated contact in map (with new lastAdvert)');
|
debugPrint(' ✅ Updated contact in map (with new lastAdvert)');
|
||||||
@@ -441,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) {
|
bool _isInvalidTelemetryGps(LatLng? location) {
|
||||||
if (location == null) return false;
|
if (location == null) return false;
|
||||||
final lat = location.latitude;
|
final lat = location.latitude;
|
||||||
@@ -460,15 +532,12 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return location;
|
return location;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _shouldRetainLastValidGps(Contact contact, LatLng? incomingGps) {
|
bool _shouldRetainLastValidGps(
|
||||||
final isSupportedType =
|
ContactTelemetry? existingTelemetry,
|
||||||
contact.isChat || contact.isRepeater || contact.isRoom;
|
LatLng? incomingGps,
|
||||||
if (!isSupportedType) {
|
) {
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final hasPreviousValidGps =
|
final hasPreviousValidGps =
|
||||||
_getValidGpsOrNull(contact.telemetry?.gpsLocation) != null;
|
_getValidGpsOrNull(existingTelemetry?.gpsLocation) != null;
|
||||||
if (!hasPreviousValidGps) {
|
if (!hasPreviousValidGps) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -476,6 +545,46 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
return incomingGps == null;
|
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)
|
/// Find contact by public key prefix (6 bytes)
|
||||||
Contact? _findContactByPrefix(Uint8List prefix) {
|
Contact? _findContactByPrefix(Uint8List prefix) {
|
||||||
if (prefix.length < 6) return null;
|
if (prefix.length < 6) return null;
|
||||||
@@ -521,7 +630,39 @@ class ContactsProvider with ChangeNotifier {
|
|||||||
/// prefer flood routing until the radio reports a fresh route.
|
/// prefer flood routing until the radio reports a fresh route.
|
||||||
void markPathUnhealthy(Uint8List publicKey) {
|
void markPathUnhealthy(Uint8List publicKey) {
|
||||||
final contact = findContactByKey(publicKey);
|
final contact = findContactByKey(publicKey);
|
||||||
if (contact == null || !contact.hasPath) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ class MessageDeliveryTracker {
|
|||||||
return _ackTagToMessageId[ackCode];
|
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
|
/// Remove ACK tag mapping after delivery confirmed or timeout
|
||||||
///
|
///
|
||||||
/// Cleans up both forward and reverse mappings.
|
/// Cleans up both forward and reverse mappings.
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
|
||||||
/// Manages message retry state and logic
|
/// Manages message retry state and logic
|
||||||
///
|
///
|
||||||
/// This helper class centralizes retry logic for direct messages, implementing
|
/// This helper class centralizes retry logic for direct messages.
|
||||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
|
||||||
/// with learned routing paths.
|
|
||||||
///
|
///
|
||||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||||
/// - Firmware calculates timeout based on path length and airtime
|
/// - Firmware calculates timeout based on path length and airtime
|
||||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||||
/// - Flood mode: ~10-30 seconds for multi-hop
|
/// - 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
|
/// - Firmware does NOT automatically retry - app must implement
|
||||||
class MessageRetryManager {
|
class MessageRetryManager {
|
||||||
// Track retry state for each message ID
|
// Track retry state for each message ID
|
||||||
@@ -22,10 +19,10 @@ class MessageRetryManager {
|
|||||||
final Map<String, DateTime> _lastRetryTimes = {};
|
final Map<String, DateTime> _lastRetryTimes = {};
|
||||||
final Map<String, int> _pathFailureStreaks = {};
|
final Map<String, int> _pathFailureStreaks = {};
|
||||||
|
|
||||||
// Progressive timeout values in milliseconds
|
static const int maxRetryAttempts = 4;
|
||||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
|
||||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
// Retry backoff values in milliseconds.
|
||||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
|
||||||
static const int _defaultLoRaSf = 10;
|
static const int _defaultLoRaSf = 10;
|
||||||
static const int _defaultLoRaCr = 5;
|
static const int _defaultLoRaCr = 5;
|
||||||
static const int _defaultLoRaBwHz = 250000;
|
static const int _defaultLoRaBwHz = 250000;
|
||||||
@@ -33,13 +30,12 @@ class MessageRetryManager {
|
|||||||
static const int _defaultLoRaCrcEnabled = 1;
|
static const int _defaultLoRaCrcEnabled = 1;
|
||||||
static const int _defaultLoRaExplicitHeader = 1;
|
static const int _defaultLoRaExplicitHeader = 1;
|
||||||
|
|
||||||
/// Get timeout for a specific retry attempt (0-2)
|
/// Get backoff delay for the next retry attempt.
|
||||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
int getDelayForAttempt(int attempt) {
|
||||||
int getTimeoutForAttempt(int attempt) {
|
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
return _retryDelays.last;
|
||||||
return _timeouts.last; // Default to last timeout if out of range
|
|
||||||
}
|
}
|
||||||
return _timeouts[attempt];
|
return _retryDelays[attempt];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
||||||
@@ -55,8 +51,8 @@ class MessageRetryManager {
|
|||||||
|
|
||||||
final payloadBytes = utf8.encode(text).length;
|
final payloadBytes = utf8.encode(text).length;
|
||||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||||
final hopCount = contact?.hasPath == true
|
final hopCount = contact?.routeHasPath == true
|
||||||
? math.max(contact!.outPathLen, 0)
|
? contact!.routeHopCount
|
||||||
: -1;
|
: -1;
|
||||||
|
|
||||||
if (hopCount < 0) {
|
if (hopCount < 0) {
|
||||||
@@ -66,43 +62,8 @@ class MessageRetryManager {
|
|||||||
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
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) {
|
bool canRetry(Message message, Contact contact) {
|
||||||
// Never retry if already tried flood mode
|
return message.retryAttempt < maxRetryAttempts;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track a retry attempt for a message
|
/// Track a retry attempt for a message
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ class PingTracker {
|
|||||||
/// Mark a ping as successful (response received)
|
/// Mark a ping as successful (response received)
|
||||||
/// Should be called when telemetry response arrives
|
/// Should be called when telemetry response arrives
|
||||||
void markPingSuccessful(Uint8List publicKey) {
|
void markPingSuccessful(Uint8List publicKey) {
|
||||||
final String keyHex = _publicKeyToHex(publicKey);
|
final requestKey = _findMatchingPendingPingKey(publicKey);
|
||||||
final request = _pendingPings.remove(keyHex);
|
final request = requestKey != null ? _pendingPings.remove(requestKey) : null;
|
||||||
|
|
||||||
if (request != null) {
|
if (request != null) {
|
||||||
request.cancel();
|
request.cancel();
|
||||||
@@ -81,6 +81,23 @@ class PingTracker {
|
|||||||
String _publicKeyToHex(Uint8List publicKey) {
|
String _publicKeyToHex(Uint8List publicKey) {
|
||||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
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
|
/// Internal class to track a single ping request
|
||||||
|
|||||||
@@ -28,13 +28,13 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
|
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (requester.outPathLen < 0) {
|
if (!requester.routeHasPath) {
|
||||||
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
|
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (requester.outPathLen > maxDirectPayloadHops) {
|
if (requester.routeHopCount > maxDirectPayloadHops) {
|
||||||
debugPrint(
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
try {
|
try {
|
||||||
await sendRawPacket(
|
await sendRawPacket(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.outPathLen,
|
contactPathLen: requester.routeEncodedPathLen,
|
||||||
payload: encodeBinary(fragment),
|
payload: encodeBinary(fragment),
|
||||||
);
|
);
|
||||||
servedCount++;
|
servedCount++;
|
||||||
|
|||||||
@@ -1,38 +1,57 @@
|
|||||||
|
import '../../models/message.dart';
|
||||||
import '../../utils/image_message_parser.dart';
|
import '../../utils/image_message_parser.dart';
|
||||||
import '../../utils/voice_message_parser.dart';
|
import '../../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
class RestoredSessionMetadata {
|
class RestoredSessionMetadata {
|
||||||
final Map<String, String> voiceSenderKeyBySession;
|
final Map<String, String> voiceSenderKeyBySession;
|
||||||
|
final Map<String, String> imageSenderKeyBySession;
|
||||||
final Map<String, ImageEnvelope> imageEnvelopeBySession;
|
final Map<String, ImageEnvelope> imageEnvelopeBySession;
|
||||||
|
|
||||||
const RestoredSessionMetadata({
|
const RestoredSessionMetadata({
|
||||||
required this.voiceSenderKeyBySession,
|
required this.voiceSenderKeyBySession,
|
||||||
|
required this.imageSenderKeyBySession,
|
||||||
required this.imageEnvelopeBySession,
|
required this.imageEnvelopeBySession,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
RestoredSessionMetadata restoreSessionMetadataFromMessages(
|
RestoredSessionMetadata restoreSessionMetadataFromMessages(
|
||||||
Iterable<String> messageTexts,
|
Iterable<Message> messages,
|
||||||
) {
|
) {
|
||||||
final voiceSenderKeyBySession = <String, String>{};
|
final voiceSenderKeyBySession = <String, String>{};
|
||||||
|
final imageSenderKeyBySession = <String, String>{};
|
||||||
final imageEnvelopeBySession = <String, ImageEnvelope>{};
|
final imageEnvelopeBySession = <String, ImageEnvelope>{};
|
||||||
|
|
||||||
for (final text in messageTexts) {
|
for (final message in messages) {
|
||||||
|
final text = message.text;
|
||||||
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
|
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
|
||||||
if (voiceEnvelope != null) {
|
if (voiceEnvelope != null) {
|
||||||
voiceSenderKeyBySession[voiceEnvelope.sessionId] = voiceEnvelope
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
.senderKey6
|
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||||
.toLowerCase();
|
voiceSenderKeyBySession[voiceEnvelope.sessionId] = senderPrefix
|
||||||
|
.sublist(0, 6)
|
||||||
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final imageEnvelope = ImageEnvelope.tryParse(text);
|
final imageEnvelope = ImageEnvelope.tryParse(text);
|
||||||
if (imageEnvelope != null) {
|
if (imageEnvelope != null) {
|
||||||
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
|
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(
|
return RestoredSessionMetadata(
|
||||||
voiceSenderKeyBySession: voiceSenderKeyBySession,
|
voiceSenderKeyBySession: voiceSenderKeyBySession,
|
||||||
|
imageSenderKeyBySession: imageSenderKeyBySession,
|
||||||
imageEnvelopeBySession: imageEnvelopeBySession,
|
imageEnvelopeBySession: imageEnvelopeBySession,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,11 +96,28 @@ class ImageProvider with ChangeNotifier {
|
|||||||
return missing;
|
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 ──────────────────────────────────────────
|
// ── Incoming fragment reception ──────────────────────────────────────────
|
||||||
|
|
||||||
/// Add a received [fragment]. Creates the session on first fragment using
|
/// Add a received [fragment]. New compact fragments rely on prior envelope
|
||||||
/// metadata from the fragment itself (requires envelope to have been
|
/// metadata for total/format, while legacy fragments can still self-describe.
|
||||||
/// announced first; if not, defaults width/height to 0 — corrected on save).
|
|
||||||
///
|
///
|
||||||
/// Returns true when the session just became complete.
|
/// Returns true when the session just became complete.
|
||||||
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
||||||
@@ -110,16 +127,20 @@ class ImageProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
_sessions.putIfAbsent(
|
_sessions.putIfAbsent(fragment.sessionId, () {
|
||||||
fragment.sessionId,
|
if (fragment.total < 1) {
|
||||||
() => ImageSession(
|
throw StateError(
|
||||||
|
'Image envelope missing for compact fragment ${fragment.sessionId}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ImageSession(
|
||||||
sessionId: fragment.sessionId,
|
sessionId: fragment.sessionId,
|
||||||
format: fragment.format,
|
format: fragment.format,
|
||||||
total: fragment.total,
|
total: fragment.total,
|
||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
final session = _sessions[fragment.sessionId]!;
|
final session = _sessions[fragment.sessionId]!;
|
||||||
if (fragment.index < session.total) {
|
if (fragment.index < session.total) {
|
||||||
@@ -244,16 +265,22 @@ class ImageProvider with ChangeNotifier {
|
|||||||
required Contact requester,
|
required Contact requester,
|
||||||
Set<int>? requestedIndices,
|
Set<int>? requestedIndices,
|
||||||
}) async {
|
}) async {
|
||||||
final cached = _outgoing[sessionId];
|
final outgoing = _outgoing[sessionId];
|
||||||
if (cached == null) {
|
final fragments = outgoing != null
|
||||||
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId');
|
? 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 false;
|
||||||
}
|
}
|
||||||
return serveCachedSessionFragments<ImagePacket>(
|
return serveCachedSessionFragments<ImagePacket>(
|
||||||
providerLabel: 'ImageProvider',
|
providerLabel: 'ImageProvider',
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
requester: requester,
|
requester: requester,
|
||||||
fragments: cached.fragments,
|
fragments: fragments,
|
||||||
maxDirectPayloadHops: maxDirectPayloadHops,
|
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||||
indexOf: (fragment) => fragment.index,
|
indexOf: (fragment) => fragment.index,
|
||||||
encodeBinary: (fragment) => fragment.encodeBinary(),
|
encodeBinary: (fragment) => fragment.encodeBinary(),
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import '../models/message.dart';
|
|||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../models/message_reception_details.dart';
|
import '../models/message_reception_details.dart';
|
||||||
|
import '../models/message_transfer_details.dart';
|
||||||
|
import '../models/message_route_metadata.dart';
|
||||||
|
import '../models/path_selection.dart';
|
||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
import '../services/message_storage_service.dart';
|
import '../services/message_storage_service.dart';
|
||||||
@@ -11,6 +14,7 @@ import '../services/notification_service.dart';
|
|||||||
import '../utils/sar_message_parser.dart';
|
import '../utils/sar_message_parser.dart';
|
||||||
import '../utils/drawing_message_parser.dart';
|
import '../utils/drawing_message_parser.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
|
import '../utils/image_message_parser.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'helpers/message_retry_manager.dart';
|
import 'helpers/message_retry_manager.dart';
|
||||||
|
|
||||||
@@ -24,6 +28,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
AppLocalizations? _localizations;
|
AppLocalizations? _localizations;
|
||||||
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
final Map<String, MessageContactLocation> _messageContactLocations = {};
|
||||||
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
|
||||||
|
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
|
||||||
|
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
|
||||||
|
|
||||||
// Track pending sent messages by expected ACK/TAG
|
// Track pending sent messages by expected ACK/TAG
|
||||||
final Map<int, Message> _pendingSentMessages = {};
|
final Map<int, Message> _pendingSentMessages = {};
|
||||||
@@ -56,6 +62,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Navigation state for message highlighting/scrolling
|
// Navigation state for message highlighting/scrolling
|
||||||
String? _targetMessageId;
|
String? _targetMessageId;
|
||||||
|
String? _targetDestinationType;
|
||||||
|
String? _targetRecipientPublicKeyHex;
|
||||||
|
|
||||||
// Helper function to compare Uint8List for equality
|
// Helper function to compare Uint8List for equality
|
||||||
bool _listEquals(Uint8List a, Uint8List b) {
|
bool _listEquals(Uint8List a, Uint8List b) {
|
||||||
@@ -78,6 +86,28 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> Function({required Contact contact, required int failureStreak})?
|
Future<void> Function({required Contact contact, required int failureStreak})?
|
||||||
onDirectPathFailedCallback;
|
onDirectPathFailedCallback;
|
||||||
|
Future<bool> Function({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required Message message,
|
||||||
|
})?
|
||||||
|
onFinalRouterFallbackCallback;
|
||||||
|
Future<void> Function({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required Message message,
|
||||||
|
})?
|
||||||
|
onFinalDirectMessageFailureCallback;
|
||||||
|
void Function({
|
||||||
|
required String messageId,
|
||||||
|
required Contact contact,
|
||||||
|
required Message message,
|
||||||
|
required int roundTripTimeMs,
|
||||||
|
})?
|
||||||
|
onDirectMessageDeliveredCallback;
|
||||||
|
|
||||||
|
String? Function(Uint8List? publicKey)? resolveContactNameCallback;
|
||||||
|
String Function(int channelIdx)? resolveChannelNameCallback;
|
||||||
|
|
||||||
List<Message> get messages => List.unmodifiable(_messages);
|
List<Message> get messages => List.unmodifiable(_messages);
|
||||||
|
|
||||||
@@ -117,6 +147,33 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
MessageReceptionDetails? getMessageReceptionDetails(String messageId) =>
|
MessageReceptionDetails? getMessageReceptionDetails(String messageId) =>
|
||||||
_messageReceptionDetails[messageId];
|
_messageReceptionDetails[messageId];
|
||||||
|
|
||||||
|
MessageTransferDetails? getMessageTransferDetails(String messageId) =>
|
||||||
|
_messageTransferDetails[messageId];
|
||||||
|
|
||||||
|
MessageRouteMetadata? getMessageRouteMetadata(String messageId) =>
|
||||||
|
_messageRouteMetadata[messageId];
|
||||||
|
|
||||||
|
void updateMessageRouteSelection(
|
||||||
|
String messageId,
|
||||||
|
PathSelection selection, {
|
||||||
|
required bool routerFallbackAttempted,
|
||||||
|
}) {
|
||||||
|
_messageRouteMetadata[messageId] = MessageRouteMetadata.fromSelection(
|
||||||
|
selection,
|
||||||
|
routerFallbackAttempted: routerFallbackAttempted,
|
||||||
|
);
|
||||||
|
|
||||||
|
final index = _messages.indexWhere((message) => message.id == messageId);
|
||||||
|
if (index != -1) {
|
||||||
|
_messages[index] = _messages[index].copyWith(
|
||||||
|
usedFloodFallback: selection.usesFlood,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistMessages();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Set localizations for notifications
|
/// Set localizations for notifications
|
||||||
void setLocalizations(AppLocalizations localizations) {
|
void setLocalizations(AppLocalizations localizations) {
|
||||||
_localizations = localizations;
|
_localizations = localizations;
|
||||||
@@ -128,16 +185,83 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? get targetDestinationType => _targetDestinationType;
|
||||||
|
String? get targetRecipientPublicKeyHex => _targetRecipientPublicKeyHex;
|
||||||
|
|
||||||
|
void navigateToDestination(String type, {String? recipientPublicKeyHex}) {
|
||||||
|
_targetDestinationType = type;
|
||||||
|
_targetRecipientPublicKeyHex = recipientPublicKeyHex;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear message navigation state
|
/// Clear message navigation state
|
||||||
void clearMessageNavigation() {
|
void clearMessageNavigation() {
|
||||||
_targetMessageId = null;
|
_targetMessageId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void clearDestinationNavigation() {
|
||||||
|
_targetDestinationType = null;
|
||||||
|
_targetRecipientPublicKeyHex = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get count of unread messages (excluding sent messages and system messages)
|
/// Get count of unread messages (excluding sent messages and system messages)
|
||||||
int get unreadCount => _messages
|
int get unreadCount => _messages
|
||||||
.where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage)
|
.where((m) => !m.isRead && !m.isSentMessage && !m.isSystemMessage)
|
||||||
.length;
|
.length;
|
||||||
|
|
||||||
|
int getMessageCountForDestination(Contact contact) => _messages
|
||||||
|
.where((message) => _isMessageForDestination(message, contact))
|
||||||
|
.length;
|
||||||
|
|
||||||
|
int getUnreadCountForDestination(Contact contact) => _messages
|
||||||
|
.where(
|
||||||
|
(message) =>
|
||||||
|
_isMessageForDestination(message, contact) &&
|
||||||
|
!message.isRead &&
|
||||||
|
!message.isSentMessage &&
|
||||||
|
!message.isSystemMessage,
|
||||||
|
)
|
||||||
|
.length;
|
||||||
|
|
||||||
|
bool _isMessageForDestination(Message message, Contact contact) {
|
||||||
|
if (message.isSystemMessage) return false;
|
||||||
|
|
||||||
|
if (contact.isChannel) {
|
||||||
|
final channelIdx = contact.publicKey.length > 1
|
||||||
|
? contact.publicKey[1]
|
||||||
|
: 0;
|
||||||
|
return message.isChannelMessage &&
|
||||||
|
(message.channelIdx ?? 0) == channelIdx;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message.isContactMessage || contact.publicKey.length < 6) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedPrefix = contact.publicKey.sublist(0, 6);
|
||||||
|
|
||||||
|
if (message.recipientPublicKey != null &&
|
||||||
|
message.recipientPublicKey!.length >= 6 &&
|
||||||
|
_matchesPrefix(message.recipientPublicKey!, selectedPrefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.senderPublicKeyPrefix != null &&
|
||||||
|
message.senderPublicKeyPrefix!.length >= 6 &&
|
||||||
|
_matchesPrefix(message.senderPublicKeyPrefix!, selectedPrefix)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _matchesPrefix(Uint8List key, Uint8List prefix) {
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
if (key[i] != prefix[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Initialize and load persisted messages
|
/// Initialize and load persisted messages
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
@@ -149,12 +273,22 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
.loadMessageContactLocations();
|
.loadMessageContactLocations();
|
||||||
final storedReceptionDetails = await _storageService
|
final storedReceptionDetails = await _storageService
|
||||||
.loadMessageReceptionDetails();
|
.loadMessageReceptionDetails();
|
||||||
|
final storedTransferDetails = await _storageService
|
||||||
|
.loadMessageTransferDetails();
|
||||||
|
final storedRouteMetadata = await _storageService
|
||||||
|
.loadMessageRouteMetadata();
|
||||||
_messageContactLocations
|
_messageContactLocations
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedContactLocations);
|
..addAll(storedContactLocations);
|
||||||
_messageReceptionDetails
|
_messageReceptionDetails
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(storedReceptionDetails);
|
..addAll(storedReceptionDetails);
|
||||||
|
_messageTransferDetails
|
||||||
|
..clear()
|
||||||
|
..addAll(storedTransferDetails);
|
||||||
|
_messageRouteMetadata
|
||||||
|
..clear()
|
||||||
|
..addAll(storedRouteMetadata);
|
||||||
|
|
||||||
// Add stored messages with enhancement to ensure SAR detection
|
// Add stored messages with enhancement to ensure SAR detection
|
||||||
for (final message in storedMessages) {
|
for (final message in storedMessages) {
|
||||||
@@ -194,14 +328,6 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
isVoice: true,
|
isVoice: true,
|
||||||
voiceId: envelope.sessionId,
|
voiceId: envelope.sessionId,
|
||||||
);
|
);
|
||||||
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
|
||||||
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
|
||||||
if (pkt != null) {
|
|
||||||
enhancedMessage = enhancedMessage.copyWith(
|
|
||||||
isVoice: true,
|
|
||||||
voiceId: pkt.sessionId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,14 +475,6 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
isVoice: true,
|
isVoice: true,
|
||||||
voiceId: envelope.sessionId,
|
voiceId: envelope.sessionId,
|
||||||
);
|
);
|
||||||
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
|
||||||
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
|
||||||
if (pkt != null) {
|
|
||||||
enhancedMessage = enhancedMessage.copyWith(
|
|
||||||
isVoice: true,
|
|
||||||
voiceId: pkt.sessionId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,33 +694,31 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
/// Trigger notification for regular message
|
/// Trigger notification for regular message
|
||||||
Future<void> _triggerMessageNotification(Message message) async {
|
Future<void> _triggerMessageNotification(Message message) async {
|
||||||
try {
|
try {
|
||||||
// Get sender name from message
|
final senderName = _resolveParticipantName(
|
||||||
final senderName =
|
publicKey: message.senderPublicKeyPrefix,
|
||||||
message.senderName ?? message.senderKeyShort ?? 'Unknown';
|
fallback: message.senderName ?? message.senderKeyShort,
|
||||||
|
);
|
||||||
// Determine if it's a channel message
|
|
||||||
final isChannelMessage = message.isChannelMessage;
|
final isChannelMessage = message.isChannelMessage;
|
||||||
|
final channelName = isChannelMessage
|
||||||
// Get channel name if available
|
? _resolveChannelName(message.channelIdx)
|
||||||
String? channelName;
|
: null;
|
||||||
if (isChannelMessage) {
|
final messageText = _buildNotificationMessageText(
|
||||||
// You could map channelIdx to channel name here if needed
|
message,
|
||||||
// For now, use "Public" for channel 0
|
senderName: senderName,
|
||||||
channelName = message.channelIdx == 0
|
isChannelMessage: isChannelMessage,
|
||||||
? 'Public'
|
channelName: channelName,
|
||||||
: 'Channel ${message.channelIdx}';
|
);
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('🔔 [MessagesProvider] Triggering message notification');
|
debugPrint('🔔 [MessagesProvider] Triggering message notification');
|
||||||
debugPrint(' Sender: $senderName');
|
debugPrint(' Sender: $senderName');
|
||||||
debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}');
|
debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}');
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' Message: ${message.text.substring(0, message.text.length > 50 ? 50 : message.text.length)}...',
|
' Message: ${messageText.substring(0, messageText.length > 50 ? 50 : messageText.length)}...',
|
||||||
);
|
);
|
||||||
|
|
||||||
await _notificationService.showMessageNotification(
|
await _notificationService.showMessageNotification(
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
messageText: message.text,
|
messageText: messageText,
|
||||||
isChannelMessage: isChannelMessage,
|
isChannelMessage: isChannelMessage,
|
||||||
channelName: channelName,
|
channelName: channelName,
|
||||||
localizations: _localizations,
|
localizations: _localizations,
|
||||||
@@ -614,6 +730,72 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _resolveParticipantName({
|
||||||
|
required Uint8List? publicKey,
|
||||||
|
String? fallback,
|
||||||
|
}) {
|
||||||
|
final resolved = resolveContactNameCallback?.call(publicKey)?.trim();
|
||||||
|
if (resolved != null && resolved.isNotEmpty) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
final normalizedFallback = fallback?.trim();
|
||||||
|
if (normalizedFallback != null && normalizedFallback.isNotEmpty) {
|
||||||
|
return normalizedFallback;
|
||||||
|
}
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _resolveChannelName(int? channelIdx) {
|
||||||
|
final idx = channelIdx ?? 0;
|
||||||
|
final resolved = resolveChannelNameCallback?.call(idx).trim();
|
||||||
|
if (resolved != null && resolved.isNotEmpty) {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
return idx == 0 ? 'Public' : 'Channel $idx';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildNotificationMessageText(
|
||||||
|
Message message, {
|
||||||
|
required String senderName,
|
||||||
|
required bool isChannelMessage,
|
||||||
|
String? channelName,
|
||||||
|
}) {
|
||||||
|
final voiceEnvelope = VoiceEnvelope.tryParseText(message.text);
|
||||||
|
if (voiceEnvelope != null) {
|
||||||
|
final seconds = (voiceEnvelope.durationMs / 1000).ceil();
|
||||||
|
final summary =
|
||||||
|
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
final imageEnvelope = ImageEnvelope.tryParse(message.text);
|
||||||
|
if (imageEnvelope != null) {
|
||||||
|
final summary =
|
||||||
|
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isChannelMessage && message.recipientPublicKey != null) {
|
||||||
|
final recipientName = _resolveParticipantName(
|
||||||
|
publicKey: message.recipientPublicKey,
|
||||||
|
fallback: null,
|
||||||
|
);
|
||||||
|
if (recipientName != 'Unknown') {
|
||||||
|
return 'To: $recipientName\n${message.text}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return message.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatBytes(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
final kib = bytes / 1024;
|
||||||
|
if (kib < 1024) return '${kib.toStringAsFixed(kib >= 10 ? 0 : 1)} KB';
|
||||||
|
final mib = kib / 1024;
|
||||||
|
return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB';
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist messages to storage (async, non-blocking)
|
/// Persist messages to storage (async, non-blocking)
|
||||||
Future<void> _persistMessages() async {
|
Future<void> _persistMessages() async {
|
||||||
try {
|
try {
|
||||||
@@ -621,6 +803,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_messages,
|
_messages,
|
||||||
messageContactLocations: _messageContactLocations,
|
messageContactLocations: _messageContactLocations,
|
||||||
messageReceptionDetails: _messageReceptionDetails,
|
messageReceptionDetails: _messageReceptionDetails,
|
||||||
|
messageTransferDetails: _messageTransferDetails,
|
||||||
|
messageRouteMetadata: _messageRouteMetadata,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
|
||||||
@@ -701,6 +885,54 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark unread messages for a specific destination as read.
|
||||||
|
void markDestinationAsRead({
|
||||||
|
required String destinationType,
|
||||||
|
Contact? contact,
|
||||||
|
}) {
|
||||||
|
if (destinationType == 'all') {
|
||||||
|
markAllAsRead();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool hasChanges = false;
|
||||||
|
for (int i = 0; i < _messages.length; i++) {
|
||||||
|
final message = _messages[i];
|
||||||
|
if (message.isRead || message.isSentMessage || message.isSystemMessage) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final matchesDestination = switch (destinationType) {
|
||||||
|
'channel' => _isChannelMessageForContact(message, contact),
|
||||||
|
'contact' || 'room' => contact != null && _isMessageForDestination(message, contact),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!matchesDestination) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_messages[i] = message.copyWith(isRead: true);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
_persistMessages();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isChannelMessageForContact(Message message, Contact? contact) {
|
||||||
|
if (!message.isChannelMessage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final selectedChannelIdx = contact != null && contact.publicKey.length > 1
|
||||||
|
? contact.publicKey[1]
|
||||||
|
: 0;
|
||||||
|
return (message.channelIdx ?? 0) == selectedChannelIdx;
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark a specific message as read
|
/// Mark a specific message as read
|
||||||
void markAsRead(String messageId) {
|
void markAsRead(String messageId) {
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
@@ -738,6 +970,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_groupedMessageMapping.remove(messageId);
|
_groupedMessageMapping.remove(messageId);
|
||||||
_messageContactLocations.remove(messageId);
|
_messageContactLocations.remove(messageId);
|
||||||
_messageReceptionDetails.remove(messageId);
|
_messageReceptionDetails.remove(messageId);
|
||||||
|
_messageTransferDetails.remove(messageId);
|
||||||
|
_messageRouteMetadata.remove(messageId);
|
||||||
|
|
||||||
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
|
||||||
|
|
||||||
@@ -768,6 +1002,8 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
|
_messageTransferDetails.clear();
|
||||||
|
_messageRouteMetadata.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -784,10 +1020,70 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_sarMarkers.clear();
|
_sarMarkers.clear();
|
||||||
_messageContactLocations.clear();
|
_messageContactLocations.clear();
|
||||||
_messageReceptionDetails.clear();
|
_messageReceptionDetails.clear();
|
||||||
|
_messageTransferDetails.clear();
|
||||||
|
_messageRouteMetadata.clear();
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int transferCountForSession({
|
||||||
|
String? voiceSessionId,
|
||||||
|
String? imageSessionId,
|
||||||
|
}) {
|
||||||
|
final messageId = _findMessageIdByMediaSession(
|
||||||
|
voiceSessionId: voiceSessionId,
|
||||||
|
imageSessionId: imageSessionId,
|
||||||
|
);
|
||||||
|
if (messageId == null) return 0;
|
||||||
|
return _messageTransferDetails[messageId]?.totalTransfers ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordMediaTransfer({
|
||||||
|
required String sessionId,
|
||||||
|
required String mediaType,
|
||||||
|
required String requesterKey6,
|
||||||
|
String? requesterName,
|
||||||
|
}) {
|
||||||
|
final messageId = _findMessageIdByMediaSession(
|
||||||
|
voiceSessionId: mediaType == 'voice' ? sessionId : null,
|
||||||
|
imageSessionId: mediaType == 'image' ? sessionId : null,
|
||||||
|
);
|
||||||
|
if (messageId == null) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [MessagesProvider] No message found for $mediaType session $sessionId',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final current =
|
||||||
|
_messageTransferDetails[messageId] ??
|
||||||
|
const MessageTransferDetails.empty();
|
||||||
|
_messageTransferDetails[messageId] = current.registerTransfer(
|
||||||
|
requesterKey6: requesterKey6,
|
||||||
|
requesterName: requesterName,
|
||||||
|
);
|
||||||
|
_persistMessages();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _findMessageIdByMediaSession({
|
||||||
|
String? voiceSessionId,
|
||||||
|
String? imageSessionId,
|
||||||
|
}) {
|
||||||
|
for (final message in _messages.reversed) {
|
||||||
|
if (voiceSessionId != null && message.voiceId == voiceSessionId) {
|
||||||
|
return message.id;
|
||||||
|
}
|
||||||
|
if (imageSessionId != null) {
|
||||||
|
final envelope = ImageEnvelope.tryParse(message.text);
|
||||||
|
if (envelope != null && envelope.sessionId == imageSessionId) {
|
||||||
|
return message.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
/// Get storage statistics
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
Future<Map<String, dynamic>> getStorageStats() async {
|
||||||
return await _storageService.getStorageStats();
|
return await _storageService.getStorageStats();
|
||||||
@@ -883,14 +1179,6 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
isVoice: true,
|
isVoice: true,
|
||||||
voiceId: envelope.sessionId,
|
voiceId: envelope.sessionId,
|
||||||
);
|
);
|
||||||
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
|
|
||||||
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
|
|
||||||
if (pkt != null) {
|
|
||||||
enhancedMessage = enhancedMessage.copyWith(
|
|
||||||
isVoice: true,
|
|
||||||
voiceId: pkt.sessionId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1429,6 +1717,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final deliveredContact = _messageContactMap[message.id];
|
final deliveredContact = _messageContactMap[message.id];
|
||||||
if (deliveredContact != null) {
|
if (deliveredContact != null) {
|
||||||
_retryManager.recordDeliverySuccess(deliveredContact);
|
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||||
|
onDirectMessageDeliveredCallback?.call(
|
||||||
|
messageId: message.id,
|
||||||
|
contact: deliveredContact,
|
||||||
|
message: updatedMessage,
|
||||||
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1472,6 +1766,12 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
final deliveredContact = _messageContactMap[historicalMessageId];
|
final deliveredContact = _messageContactMap[historicalMessageId];
|
||||||
if (deliveredContact != null) {
|
if (deliveredContact != null) {
|
||||||
_retryManager.recordDeliverySuccess(deliveredContact);
|
_retryManager.recordDeliverySuccess(deliveredContact);
|
||||||
|
onDirectMessageDeliveredCallback?.call(
|
||||||
|
messageId: historicalMessageId,
|
||||||
|
contact: deliveredContact,
|
||||||
|
message: _messages[historicalIndex],
|
||||||
|
roundTripTimeMs: roundTripTimeMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -1554,32 +1854,32 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
|
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
|
||||||
debugPrint(' Retry attempt: ${message.retryAttempt}');
|
debugPrint(' Retry attempt: ${message.retryAttempt}');
|
||||||
debugPrint(' Contact has path: ${contact?.hasPath ?? false}');
|
debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}');
|
||||||
debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
|
debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
|
||||||
|
|
||||||
// Decision tree for retry/flood/fail
|
final routeMetadata = _messageRouteMetadata[messageId];
|
||||||
|
final routerFallbackAttempted =
|
||||||
|
routeMetadata?.routerFallbackAttempted ?? false;
|
||||||
|
|
||||||
|
// Decision tree for retry/final-router-fallback/fail
|
||||||
if (contact != null && _retryManager.canRetry(message, contact)) {
|
if (contact != null && _retryManager.canRetry(message, contact)) {
|
||||||
// RETRY: Contact has path and retry attempts < 3
|
|
||||||
_scheduleRetry(messageId, message, contact);
|
_scheduleRetry(messageId, message, contact);
|
||||||
} else if (contact != null &&
|
} else if (contact != null && !routerFallbackAttempted) {
|
||||||
_retryManager.shouldUseFloodFallback(message, contact)) {
|
unawaited(_sendWithFinalRouterFallback(messageId, message, contact));
|
||||||
// FLOOD FALLBACK: After 3 retries failed, try flood once
|
|
||||||
_sendWithFloodMode(messageId, message, contact);
|
|
||||||
} else {
|
} else {
|
||||||
// PERMANENTLY FAILED: No retry possible
|
|
||||||
_markAsPermanentlyFailed(messageId, message);
|
_markAsPermanentlyFailed(messageId, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule a retry with progressive timeout
|
/// Schedule a retry with exponential backoff.
|
||||||
void _scheduleRetry(String messageId, Message message, Contact contact) {
|
void _scheduleRetry(String messageId, Message message, Contact contact) {
|
||||||
final nextAttempt = message.retryAttempt + 1;
|
final nextAttempt = message.retryAttempt + 1;
|
||||||
final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt);
|
final delayMs = _retryManager.getDelayForAttempt(message.retryAttempt);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId',
|
'🔄 [MessagesProvider] Scheduling retry $nextAttempt/${MessageRetryManager.maxRetryAttempts} for message $messageId',
|
||||||
);
|
);
|
||||||
debugPrint(' Timeout: ${timeout}ms');
|
debugPrint(' Delay: ${delayMs}ms');
|
||||||
|
|
||||||
// Update message with new retry attempt
|
// Update message with new retry attempt
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
@@ -1600,10 +1900,10 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
// Track retry
|
// Track retry
|
||||||
_retryManager.trackRetry(messageId, nextAttempt);
|
_retryManager.trackRetry(messageId, nextAttempt);
|
||||||
|
|
||||||
notifyListeners(); // Update UI to show "Retrying (X/3)..."
|
notifyListeners();
|
||||||
|
|
||||||
// Schedule actual retry after delay
|
// Schedule actual retry after delay
|
||||||
Timer(Duration(milliseconds: timeout), () async {
|
Timer(Duration(milliseconds: delayMs), () async {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
|
||||||
);
|
);
|
||||||
@@ -1639,49 +1939,46 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send message with flood mode as last resort
|
Future<void> _sendWithFinalRouterFallback(
|
||||||
Future<void> _sendWithFloodMode(
|
|
||||||
String messageId,
|
String messageId,
|
||||||
Message message,
|
Message message,
|
||||||
Contact contact,
|
Contact contact,
|
||||||
) async {
|
) async {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'🌊 [MessagesProvider] Trying flood mode for message $messageId',
|
'🛟 [MessagesProvider] Trying final router fallback for $messageId',
|
||||||
);
|
);
|
||||||
|
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
_messages[index] = message.copyWith(
|
_messages[index] = message.copyWith(
|
||||||
usedFloodFallback: true,
|
|
||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
lastRetryAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Cancel old timeout timer
|
|
||||||
_timeoutTimers[message.id]?.cancel();
|
_timeoutTimers[message.id]?.cancel();
|
||||||
_timeoutTimers.remove(message.id);
|
_timeoutTimers.remove(message.id);
|
||||||
if (message.expectedAckTag != null) {
|
if (message.expectedAckTag != null) {
|
||||||
_pendingSentMessages.remove(message.expectedAckTag);
|
_pendingSentMessages.remove(message.expectedAckTag);
|
||||||
}
|
}
|
||||||
|
_clearAckHistoryForMessage(messageId);
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Send with flood mode (no retry after this)
|
if (onFinalRouterFallbackCallback == null) {
|
||||||
if (sendMessageCallback != null) {
|
|
||||||
final queued = await sendMessageCallback!(
|
|
||||||
contactPublicKey: contact.publicKey,
|
|
||||||
text: message.text,
|
|
||||||
messageId: messageId,
|
|
||||||
contact: contact,
|
|
||||||
retryAttempt: 0, // Reset attempt for flood
|
|
||||||
);
|
|
||||||
if (!queued) {
|
|
||||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
|
'⚠️ [MessagesProvider] onFinalRouterFallbackCallback not set',
|
||||||
);
|
);
|
||||||
_markAsPermanentlyFailed(messageId, _messages[index]);
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final queued = await onFinalRouterFallbackCallback!(
|
||||||
|
messageId: messageId,
|
||||||
|
contact: contact,
|
||||||
|
message: _messages[index],
|
||||||
|
);
|
||||||
|
if (!queued) {
|
||||||
|
_markAsPermanentlyFailed(messageId, _messages[index]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -1710,19 +2007,15 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
_retryManager.clearRetry(messageId);
|
_retryManager.clearRetry(messageId);
|
||||||
|
|
||||||
final failedContact = _messageContactMap[messageId];
|
final failedContact = _messageContactMap[messageId];
|
||||||
if (failedContact != null && failedContact.hasPath) {
|
if (failedContact != null &&
|
||||||
final failureStreak = _retryManager.recordPathFailure(failedContact);
|
onFinalDirectMessageFailureCallback != null) {
|
||||||
debugPrint(
|
unawaited(
|
||||||
' Path failure streak for ${failedContact.advName}: $failureStreak',
|
onFinalDirectMessageFailureCallback!(
|
||||||
|
messageId: messageId,
|
||||||
|
contact: failedContact,
|
||||||
|
message: _messages[index],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (failureStreak >= 2 && onDirectPathFailedCallback != null) {
|
|
||||||
unawaited(
|
|
||||||
onDirectPathFailedCallback!(
|
|
||||||
contact: failedContact,
|
|
||||||
failureStreak: failureStreak,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_persistMessages();
|
_persistMessages();
|
||||||
@@ -1730,8 +2023,70 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reset an existing failed message back into a sending state so a manual
|
||||||
|
/// retry can reuse the same record instead of appending a duplicate.
|
||||||
|
bool prepareMessageForRetry(String messageId) {
|
||||||
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
|
if (index == -1) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [MessagesProvider] prepareMessageForRetry: Message not found: $messageId',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final message = _messages[index];
|
||||||
|
|
||||||
|
_timeoutTimers[message.id]?.cancel();
|
||||||
|
_timeoutTimers.remove(message.id);
|
||||||
|
if (message.expectedAckTag != null) {
|
||||||
|
_pendingSentMessages.remove(message.expectedAckTag);
|
||||||
|
}
|
||||||
|
_clearAckHistoryForMessage(messageId);
|
||||||
|
_retryManager.clearRetry(messageId);
|
||||||
|
_messageRouteMetadata.remove(messageId);
|
||||||
|
|
||||||
|
_messages[index] = Message(
|
||||||
|
id: message.id,
|
||||||
|
messageType: message.messageType,
|
||||||
|
senderPublicKeyPrefix: message.senderPublicKeyPrefix,
|
||||||
|
channelIdx: message.channelIdx,
|
||||||
|
pathLen: message.pathLen,
|
||||||
|
textType: message.textType,
|
||||||
|
senderTimestamp: message.senderTimestamp,
|
||||||
|
text: message.text,
|
||||||
|
isSarMarker: message.isSarMarker,
|
||||||
|
sarGpsCoordinates: message.sarGpsCoordinates,
|
||||||
|
sarNotes: message.sarNotes,
|
||||||
|
sarCustomEmoji: message.sarCustomEmoji,
|
||||||
|
sarColorIndex: message.sarColorIndex,
|
||||||
|
receivedAt: message.receivedAt,
|
||||||
|
senderName: message.senderName,
|
||||||
|
deliveryStatus: MessageDeliveryStatus.sending,
|
||||||
|
recipientPublicKey: message.recipientPublicKey,
|
||||||
|
retryAttempt: 0,
|
||||||
|
lastRetryAt: DateTime.now(),
|
||||||
|
usedFloodFallback: false,
|
||||||
|
isRead: message.isRead,
|
||||||
|
echoCount: message.echoCount,
|
||||||
|
firstEchoAt: message.firstEchoAt,
|
||||||
|
lastEchoSnrRaw: message.lastEchoSnrRaw,
|
||||||
|
lastEchoRssiDbm: message.lastEchoRssiDbm,
|
||||||
|
lastEchoAt: message.lastEchoAt,
|
||||||
|
isDrawing: message.isDrawing,
|
||||||
|
drawingId: message.drawingId,
|
||||||
|
groupId: message.groupId,
|
||||||
|
recipients: message.recipients,
|
||||||
|
isVoice: message.isVoice,
|
||||||
|
voiceId: message.voiceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
_persistMessages();
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Resend a failed message
|
/// Resend a failed message
|
||||||
Future<void> resendMessage(String messageId) async {
|
Future<void> resendMessage(String messageId, {Contact? contact}) async {
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
final index = _messages.indexWhere((m) => m.id == messageId);
|
||||||
if (index == -1) {
|
if (index == -1) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -1741,9 +2096,9 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final message = _messages[index];
|
final message = _messages[index];
|
||||||
final contact = _messageContactMap[messageId];
|
final resolvedContact = contact ?? _messageContactMap[messageId];
|
||||||
|
|
||||||
if (contact == null) {
|
if (resolvedContact == null) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId',
|
'⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId',
|
||||||
);
|
);
|
||||||
@@ -1752,26 +2107,19 @@ class MessagesProvider with ChangeNotifier {
|
|||||||
|
|
||||||
debugPrint('🔁 [MessagesProvider] Resending message $messageId');
|
debugPrint('🔁 [MessagesProvider] Resending message $messageId');
|
||||||
|
|
||||||
// Reset retry state
|
_messageContactMap[messageId] = resolvedContact;
|
||||||
_messages[index] = message.copyWith(
|
final prepared = prepareMessageForRetry(messageId);
|
||||||
retryAttempt: 0,
|
if (!prepared) {
|
||||||
usedFloodFallback: false,
|
return;
|
||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
}
|
||||||
lastRetryAt: DateTime.now(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clear retry tracking
|
|
||||||
_retryManager.clearRetry(messageId);
|
|
||||||
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
// Send again
|
// Send again
|
||||||
if (sendMessageCallback != null) {
|
if (sendMessageCallback != null) {
|
||||||
final queued = await sendMessageCallback!(
|
final queued = await sendMessageCallback!(
|
||||||
contactPublicKey: contact.publicKey,
|
contactPublicKey: resolvedContact.publicKey,
|
||||||
text: message.text,
|
text: message.text,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
contact: contact,
|
contact: resolvedContact,
|
||||||
retryAttempt: 0,
|
retryAttempt: 0,
|
||||||
);
|
);
|
||||||
if (!queued) {
|
if (!queued) {
|
||||||
|
|||||||
300
lib/providers/sensors_provider.dart
Normal file
300
lib/providers/sensors_provider.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -125,6 +125,23 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
return missing;
|
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 ─────────────────────────────────────────────────────
|
// ── Packet reception ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Add an incoming [packet] to its session. Creates the session on first packet.
|
/// Add an incoming [packet] to its session. Creates the session on first packet.
|
||||||
@@ -136,14 +153,18 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
_sessions.putIfAbsent(
|
_sessions.putIfAbsent(packet.sessionId, () {
|
||||||
packet.sessionId,
|
if (packet.total < 1) {
|
||||||
() => VoiceSession(
|
throw StateError(
|
||||||
|
'Voice envelope missing for compact packet ${packet.sessionId}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return VoiceSession(
|
||||||
sessionId: packet.sessionId,
|
sessionId: packet.sessionId,
|
||||||
mode: packet.mode,
|
mode: packet.mode,
|
||||||
total: packet.total,
|
total: packet.total,
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
|
|
||||||
final session = _sessions[packet.sessionId]!;
|
final session = _sessions[packet.sessionId]!;
|
||||||
if (packet.index < session.total) {
|
if (packet.index < session.total) {
|
||||||
@@ -179,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.
|
/// Cache encoded packets for deferred voice serving.
|
||||||
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
||||||
if (packets.isEmpty) return;
|
if (packets.isEmpty) return;
|
||||||
@@ -195,10 +257,14 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
required Contact requester,
|
required Contact requester,
|
||||||
Set<int>? requestedIndices,
|
Set<int>? requestedIndices,
|
||||||
}) async {
|
}) async {
|
||||||
final cached = _outgoingSessions[sessionId];
|
final outgoing = _outgoingSessions[sessionId];
|
||||||
if (cached == null) {
|
final packets = outgoing != null
|
||||||
|
? List<VoicePacket>.from(outgoing.packets)
|
||||||
|
: _sessions[sessionId]?.packets.whereType<VoicePacket>().toList() ??
|
||||||
|
const <VoicePacket>[];
|
||||||
|
if (packets.isEmpty) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [VoiceProvider] No cached outgoing session for $sessionId',
|
'⚠️ [VoiceProvider] No cached or received session for $sessionId',
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -206,7 +272,7 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
providerLabel: 'VoiceProvider',
|
providerLabel: 'VoiceProvider',
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
requester: requester,
|
requester: requester,
|
||||||
fragments: cached.packets,
|
fragments: packets,
|
||||||
maxDirectPayloadHops: maxDirectPayloadHops,
|
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||||
indexOf: (packet) => packet.index,
|
indexOf: (packet) => packet.index,
|
||||||
encodeBinary: (packet) => packet.encodeBinary(),
|
encodeBinary: (packet) => packet.encodeBinary(),
|
||||||
|
|||||||
@@ -3,16 +3,27 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../models/contact.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../providers/messages_provider.dart';
|
||||||
|
import '../services/message_destination_preferences.dart';
|
||||||
|
import '../utils/contact_grouping.dart';
|
||||||
|
import '../utils/avatar_label_helper.dart';
|
||||||
|
import '../widgets/common/contact_avatar.dart';
|
||||||
import '../widgets/contacts/contact_tile.dart';
|
import '../widgets/contacts/contact_tile.dart';
|
||||||
import '../widgets/contacts/add_channel_dialog.dart';
|
import '../widgets/contacts/add_channel_dialog.dart';
|
||||||
|
|
||||||
class ContactsTab extends StatefulWidget {
|
class ContactsTab extends StatefulWidget {
|
||||||
final VoidCallback? onNavigateToMap;
|
final VoidCallback? onNavigateToMap;
|
||||||
|
final VoidCallback? onNavigateToMessages;
|
||||||
|
|
||||||
const ContactsTab({super.key, this.onNavigateToMap});
|
const ContactsTab({
|
||||||
|
super.key,
|
||||||
|
this.onNavigateToMap,
|
||||||
|
this.onNavigateToMessages,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ContactsTab> createState() => _ContactsTabState();
|
State<ContactsTab> createState() => _ContactsTabState();
|
||||||
@@ -21,6 +32,11 @@ class ContactsTab extends StatefulWidget {
|
|||||||
class _ContactsTabState extends State<ContactsTab> {
|
class _ContactsTabState extends State<ContactsTab> {
|
||||||
Position? _currentPosition;
|
Position? _currentPosition;
|
||||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||||
|
final Map<ContactSection, ContactSortMode> _sortModes = {
|
||||||
|
ContactSection.teamMembers: ContactSortMode.lastSeen,
|
||||||
|
ContactSection.repeaters: ContactSortMode.lastSeen,
|
||||||
|
ContactSection.rooms: ContactSortMode.lastSeen,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -119,6 +135,54 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
return l10n.daysAgo(diff.inDays);
|
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
|
/// Show the add channel dialog
|
||||||
Future<void> _showAddChannelDialog(BuildContext context) async {
|
Future<void> _showAddChannelDialog(BuildContext context) async {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
@@ -165,17 +229,31 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Consumer<ContactsProvider>(
|
body: Consumer<ContactsProvider>(
|
||||||
builder: (context, contactsProvider, child) {
|
builder: (context, contactsProvider, child) {
|
||||||
final chatContacts = contactsProvider.chatContacts;
|
final messagesProvider = context.watch<MessagesProvider>();
|
||||||
final repeaters = contactsProvider.repeaters;
|
final chatContacts = _sortContacts(
|
||||||
final rooms = contactsProvider.rooms;
|
contactsProvider.chatContacts,
|
||||||
final channels = contactsProvider.channels;
|
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;
|
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||||
|
|
||||||
// Check if there are any displayable contacts (excluding channels)
|
// Check if there are any displayable contacts
|
||||||
final hasDisplayableContacts =
|
final hasDisplayableContacts =
|
||||||
chatContacts.isNotEmpty ||
|
chatContacts.isNotEmpty ||
|
||||||
repeaters.isNotEmpty ||
|
repeaters.isNotEmpty ||
|
||||||
rooms.isNotEmpty ||
|
rooms.isNotEmpty ||
|
||||||
|
channels.isNotEmpty ||
|
||||||
pendingAdverts.isNotEmpty;
|
pendingAdverts.isNotEmpty;
|
||||||
|
|
||||||
if (!hasDisplayableContacts) {
|
if (!hasDisplayableContacts) {
|
||||||
@@ -236,16 +314,12 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
title: l10n.teamMembers,
|
title: l10n.teamMembers,
|
||||||
count: chatContacts.length,
|
count: chatContacts.length,
|
||||||
icon: Icons.people,
|
icon: Icons.people,
|
||||||
),
|
trailing: _buildSortMenu(
|
||||||
...chatContacts.map(
|
context,
|
||||||
(contact) => ContactTile(
|
ContactSection.teamMembers,
|
||||||
contact: contact,
|
|
||||||
currentPosition: _currentPosition,
|
|
||||||
calculateDistance: _calculateDistanceInMeters,
|
|
||||||
formatDistance: _formatDistance,
|
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
..._buildContactSectionItems(chatContacts),
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -255,16 +329,9 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
title: l10n.repeaters,
|
title: l10n.repeaters,
|
||||||
count: repeaters.length,
|
count: repeaters.length,
|
||||||
icon: Icons.router,
|
icon: Icons.router,
|
||||||
|
trailing: _buildSortMenu(context, ContactSection.repeaters),
|
||||||
),
|
),
|
||||||
...repeaters.map(
|
..._buildContactSectionItems(repeaters),
|
||||||
(contact) => ContactTile(
|
|
||||||
contact: contact,
|
|
||||||
currentPosition: _currentPosition,
|
|
||||||
calculateDistance: _calculateDistanceInMeters,
|
|
||||||
formatDistance: _formatDistance,
|
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -274,16 +341,9 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
title: l10n.rooms,
|
title: l10n.rooms,
|
||||||
count: rooms.length,
|
count: rooms.length,
|
||||||
icon: Icons.tag,
|
icon: Icons.tag,
|
||||||
|
trailing: _buildSortMenu(context, ContactSection.rooms),
|
||||||
),
|
),
|
||||||
...rooms.map(
|
..._buildContactSectionItems(rooms),
|
||||||
(contact) => ContactTile(
|
|
||||||
contact: contact,
|
|
||||||
currentPosition: _currentPosition,
|
|
||||||
calculateDistance: _calculateDistanceInMeters,
|
|
||||||
formatDistance: _formatDistance,
|
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 32),
|
const Divider(height: 32),
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -295,12 +355,11 @@ class _ContactsTabState extends State<ContactsTab> {
|
|||||||
),
|
),
|
||||||
if (channels.isNotEmpty) ...[
|
if (channels.isNotEmpty) ...[
|
||||||
...channels.map(
|
...channels.map(
|
||||||
(contact) => ContactTile(
|
(channel) => _ChannelActivityCard(
|
||||||
contact: contact,
|
channel: channel,
|
||||||
currentPosition: _currentPosition,
|
messagesProvider: messagesProvider,
|
||||||
calculateDistance: _calculateDistanceInMeters,
|
contactsProvider: contactsProvider,
|
||||||
formatDistance: _formatDistance,
|
onNavigateToMessages: widget.onNavigateToMessages,
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -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 {
|
class _PendingAdvertTile extends StatelessWidget {
|
||||||
final PendingAdvert advert;
|
final PendingAdvert advert;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
@@ -377,11 +525,13 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final int count;
|
final int count;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
const _SectionHeader({
|
const _SectionHeader({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.count,
|
required this.count,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
|
this.trailing,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -410,6 +560,415 @@ class _SectionHeader extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (trailing != null) ...[const Spacer(), trailing!],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InferredContactGroupCard extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final List<Contact> contacts;
|
||||||
|
final Position? currentPosition;
|
||||||
|
final double Function(double, double, double, double) calculateDistance;
|
||||||
|
final String Function(double) formatDistance;
|
||||||
|
final VoidCallback? onNavigateToMap;
|
||||||
|
final VoidCallback? onNavigateToMessages;
|
||||||
|
|
||||||
|
const _InferredContactGroupCard({
|
||||||
|
required this.label,
|
||||||
|
required this.contacts,
|
||||||
|
required this.currentPosition,
|
||||||
|
required this.calculateDistance,
|
||||||
|
required this.formatDistance,
|
||||||
|
required this.onNavigateToMap,
|
||||||
|
required this.onNavigateToMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.35),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Theme(
|
||||||
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||||
|
child: ExpansionTile(
|
||||||
|
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||||
|
childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
|
initiallyExpanded: false,
|
||||||
|
leading: Icon(
|
||||||
|
Icons.folder_copy_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
contacts.length.toString(),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
...contacts.map(
|
||||||
|
(contact) => ContactTile(
|
||||||
|
contact: contact,
|
||||||
|
currentPosition: currentPosition,
|
||||||
|
calculateDistance: calculateDistance,
|
||||||
|
formatDistance: formatDistance,
|
||||||
|
onNavigateToMap: onNavigateToMap,
|
||||||
|
onNavigateToMessages: onNavigateToMessages,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChannelActivityCard extends StatelessWidget {
|
||||||
|
final Contact channel;
|
||||||
|
final MessagesProvider messagesProvider;
|
||||||
|
final ContactsProvider contactsProvider;
|
||||||
|
final VoidCallback? onNavigateToMessages;
|
||||||
|
|
||||||
|
const _ChannelActivityCard({
|
||||||
|
required this.channel,
|
||||||
|
required this.messagesProvider,
|
||||||
|
required this.contactsProvider,
|
||||||
|
required this.onNavigateToMessages,
|
||||||
|
});
|
||||||
|
|
||||||
|
@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,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -398,26 +398,51 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
||||||
final theme = Theme.of(context);
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
|
appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)),
|
||||||
body: ListView(
|
body: SafeArea(
|
||||||
padding: const EdgeInsets.all(16),
|
child: ListView(
|
||||||
children: [
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||||
// Device Info Card
|
children: [
|
||||||
Card(
|
_ConfigHeroCard(
|
||||||
child: Padding(
|
title: deviceInfo.selfName ?? deviceInfo.deviceName ?? 'MeshCore',
|
||||||
padding: const EdgeInsets.all(16),
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.deviceInformation,
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_InfoRow(
|
_InfoRow(
|
||||||
AppLocalizations.of(context)!.bleName,
|
AppLocalizations.of(context)!.bleName,
|
||||||
deviceInfo.deviceName ??
|
deviceInfo.deviceName ??
|
||||||
@@ -467,42 +492,75 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
|
_ConfigSectionCard(
|
||||||
const SizedBox(height: 24),
|
title: AppLocalizations.of(context)!.publicInfo,
|
||||||
|
subtitle: AppLocalizations.of(context)!.nameBroadcastInMesh,
|
||||||
// Public Info Section
|
icon: Icons.public_rounded,
|
||||||
Card(
|
trailing: FilledButton.icon(
|
||||||
child: Padding(
|
onPressed: _savePublicInfo,
|
||||||
padding: const EdgeInsets.all(16),
|
icon: const Icon(Icons.save_outlined),
|
||||||
|
label: Text(AppLocalizations.of(context)!.save),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Container(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
padding: const EdgeInsets.all(14),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
Text(
|
color: colorScheme.surfaceContainerHighest.withValues(
|
||||||
AppLocalizations.of(context)!.publicInfo,
|
alpha: 0.45,
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
),
|
||||||
fontWeight: FontWeight.bold,
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_telemetryEnabled
|
||||||
|
? Icons.travel_explore
|
||||||
|
: Icons.location_disabled,
|
||||||
|
color: _telemetryEnabled
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
Row(
|
Expanded(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(width: 8),
|
children: [
|
||||||
IconButton.filled(
|
Text(
|
||||||
onPressed: _savePublicInfo,
|
AppLocalizations.of(
|
||||||
icon: const Icon(Icons.save),
|
context,
|
||||||
tooltip: AppLocalizations.of(context)!.save,
|
)!.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),
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
// Mesh Network Name
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _nameController,
|
controller: _nameController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -513,62 +571,21 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
)!.nameBroadcastInMesh,
|
)!.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) ...[
|
if (_telemetryEnabled) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: _CompactCoordinateField(
|
||||||
controller: _latController,
|
controller: _latController,
|
||||||
decoration: InputDecoration(
|
label: AppLocalizations.of(context)!.lat,
|
||||||
labelText: AppLocalizations.of(context)!.lat,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
|
||||||
decimal: true,
|
|
||||||
signed: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: _CompactCoordinateField(
|
||||||
controller: _lonController,
|
controller: _lonController,
|
||||||
decoration: InputDecoration(
|
label: AppLocalizations.of(context)!.lon,
|
||||||
labelText: AppLocalizations.of(context)!.lon,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
isDense: true,
|
|
||||||
),
|
|
||||||
keyboardType: const TextInputType.numberWithOptions(
|
|
||||||
decimal: true,
|
|
||||||
signed: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -585,36 +602,45 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
|
_ConfigSectionCard(
|
||||||
const SizedBox(height: 24),
|
title: AppLocalizations.of(context)!.radioSettings,
|
||||||
|
subtitle:
|
||||||
// Radio Settings Section
|
'${_freqController.text} MHz • SF$_selectedSpreadingFactor • CR$_selectedCodingRate',
|
||||||
Card(
|
icon: Icons.settings_input_antenna_rounded,
|
||||||
child: Padding(
|
trailing: FilledButton.icon(
|
||||||
padding: const EdgeInsets.all(16),
|
onPressed: _saveRadioSettings,
|
||||||
|
icon: const Icon(Icons.save_outlined),
|
||||||
|
label: Text(AppLocalizations.of(context)!.save),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
AppLocalizations.of(context)!.radioSettings,
|
child: _RadioMetricTile(
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
label: AppLocalizations.of(context)!.bandwidth,
|
||||||
fontWeight: FontWeight.bold,
|
value: _selectedBandwidth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton.filled(
|
const SizedBox(width: 10),
|
||||||
onPressed: _saveRadioSettings,
|
Expanded(
|
||||||
icon: const Icon(Icons.save),
|
child: _RadioMetricTile(
|
||||||
tooltip: AppLocalizations.of(context)!.save,
|
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),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// LoRa Frequency
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _freqController,
|
controller: _freqController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -629,8 +655,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Bandwidth
|
|
||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
initialValue: _selectedBandwidth,
|
initialValue: _selectedBandwidth,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -652,8 +676,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Spreading Factor
|
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
initialValue: _selectedSpreadingFactor,
|
initialValue: _selectedSpreadingFactor,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -677,8 +699,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Coding Rate
|
|
||||||
DropdownButtonFormField<int>(
|
DropdownButtonFormField<int>(
|
||||||
initialValue: _selectedCodingRate,
|
initialValue: _selectedCodingRate,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -702,8 +722,6 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// TX Power
|
|
||||||
TextField(
|
TextField(
|
||||||
controller: _txPowerController,
|
controller: _txPowerController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -715,37 +733,42 @@ class _DeviceConfigScreenState extends State<DeviceConfigScreen> {
|
|||||||
),
|
),
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Repeat Mode (firmware v9+)
|
|
||||||
if (deviceInfo.clientRepeat != null) ...[
|
if (deviceInfo.clientRepeat != null) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 16),
|
||||||
SwitchListTile(
|
Container(
|
||||||
contentPadding: EdgeInsets.zero,
|
padding: const EdgeInsets.all(14),
|
||||||
title: const Text('Client Repeat Mode'),
|
decoration: BoxDecoration(
|
||||||
subtitle:
|
color: colorScheme.surfaceContainerHighest.withValues(
|
||||||
deviceInfo.allowedRepeatFreqRanges != null &&
|
alpha: 0.45,
|
||||||
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
|
),
|
||||||
? Text(
|
borderRadius: BorderRadius.circular(18),
|
||||||
'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(', ')}',
|
),
|
||||||
)
|
child: SwitchListTile(
|
||||||
: const Text(
|
contentPadding: EdgeInsets.zero,
|
||||||
'Repeat packets on behalf of nearby nodes',
|
title: const Text('Client Repeat Mode'),
|
||||||
),
|
subtitle:
|
||||||
value: _repeatEnabled,
|
deviceInfo.allowedRepeatFreqRanges != null &&
|
||||||
onChanged: (value) {
|
deviceInfo.allowedRepeatFreqRanges!.isNotEmpty
|
||||||
setState(() {
|
? Text(
|
||||||
_repeatEnabled = value;
|
'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),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -772,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 {
|
class _InfoRow extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
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
@@ -89,9 +89,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
// Load saved message destination
|
// Load saved message destination
|
||||||
_loadSavedDestination();
|
_loadSavedDestination();
|
||||||
_loadVoiceBitrate();
|
_loadVoiceBitrate();
|
||||||
// Mark all messages as read when tab is opened
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<MessagesProvider>().markAllAsRead();
|
|
||||||
_checkForNavigationRequest();
|
_checkForNavigationRequest();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -128,11 +126,20 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
void _checkForNavigationRequest() {
|
void _checkForNavigationRequest() {
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
final targetMessageId = messagesProvider.targetMessageId;
|
final targetMessageId = messagesProvider.targetMessageId;
|
||||||
|
final targetDestinationType = messagesProvider.targetDestinationType;
|
||||||
|
|
||||||
if (targetMessageId != null) {
|
if (targetMessageId != null) {
|
||||||
_scrollToMessage(targetMessageId);
|
_scrollToMessage(targetMessageId);
|
||||||
messagesProvider.clearMessageNavigation();
|
messagesProvider.clearMessageNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetDestinationType != null) {
|
||||||
|
_applyPendingDestination(
|
||||||
|
type: targetDestinationType,
|
||||||
|
recipientPublicKeyHex: messagesProvider.targetRecipientPublicKeyHex,
|
||||||
|
);
|
||||||
|
messagesProvider.clearDestinationNavigation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _scrollToMessage(String messageId) {
|
void _scrollToMessage(String messageId) {
|
||||||
@@ -258,6 +265,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
/// Show recipient selector bottom sheet
|
/// Show recipient selector bottom sheet
|
||||||
void _showRecipientSelector() {
|
void _showRecipientSelector() {
|
||||||
final contactsProvider = context.read<ContactsProvider>();
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
|
||||||
// Filter contacts by type
|
// Filter contacts by type
|
||||||
final contacts = contactsProvider.contacts
|
final contacts = contactsProvider.contacts
|
||||||
@@ -278,6 +286,13 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
contacts: contacts,
|
contacts: contacts,
|
||||||
rooms: rooms,
|
rooms: rooms,
|
||||||
channels: channels,
|
channels: channels,
|
||||||
|
unreadCount: messagesProvider.unreadCount,
|
||||||
|
unreadCountsByPublicKey: {
|
||||||
|
for (final contact in [...contacts, ...rooms, ...channels])
|
||||||
|
contact.publicKeyHex: messagesProvider.getUnreadCountForDestination(
|
||||||
|
contact,
|
||||||
|
),
|
||||||
|
},
|
||||||
currentDestinationType: _destinationType,
|
currentDestinationType: _destinationType,
|
||||||
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
|
currentRecipientPublicKey: _selectedRecipient?.publicKeyHex,
|
||||||
onSelect: _onRecipientSelected,
|
onSelect: _onRecipientSelected,
|
||||||
@@ -292,6 +307,8 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
_selectedRecipient = recipient;
|
_selectedRecipient = recipient;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_markCurrentDestinationAsRead();
|
||||||
|
|
||||||
_enforceMessageByteLimit();
|
_enforceMessageByteLimit();
|
||||||
|
|
||||||
// Save to preferences
|
// Save to preferences
|
||||||
@@ -304,9 +321,73 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _applyPendingDestination({
|
||||||
|
required String type,
|
||||||
|
String? recipientPublicKeyHex,
|
||||||
|
}) async {
|
||||||
|
Contact? recipient;
|
||||||
|
if (recipientPublicKeyHex != null) {
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
recipient = contactsProvider.contacts.where((contact) {
|
||||||
|
return contact.publicKeyHex == recipientPublicKeyHex;
|
||||||
|
}).firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _onRecipientSelected(type, recipient);
|
||||||
|
if (!mounted) return;
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _replyToMessage(Message message) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final contactsProvider = context.read<ContactsProvider>();
|
||||||
|
|
||||||
|
String destinationType;
|
||||||
|
Contact? recipient;
|
||||||
|
|
||||||
|
if (message.isChannelMessage) {
|
||||||
|
final channelIdx = message.channelIdx ?? 0;
|
||||||
|
destinationType = MessageDestinationPreferences.destinationTypeChannel;
|
||||||
|
|
||||||
|
if (channelIdx != 0) {
|
||||||
|
recipient = contactsProvider.channels.where((contact) {
|
||||||
|
return contact.publicKey.length > 1 &&
|
||||||
|
contact.publicKey[1] == channelIdx;
|
||||||
|
}).firstOrNull;
|
||||||
|
|
||||||
|
if (recipient == null) {
|
||||||
|
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final senderPrefix = message.senderPublicKeyPrefix;
|
||||||
|
if (senderPrefix == null || senderPrefix.length < 6) {
|
||||||
|
ToastLogger.error(context, l10n.cannotReplySenderMissing);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient = contactsProvider.findContactByPrefix(senderPrefix);
|
||||||
|
if (recipient == null) {
|
||||||
|
ToastLogger.error(context, l10n.cannotReplyContactNotFound);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationType = recipient.isRoom
|
||||||
|
? MessageDestinationPreferences.destinationTypeRoom
|
||||||
|
: MessageDestinationPreferences.destinationTypeContact;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _onRecipientSelected(destinationType, recipient);
|
||||||
|
if (!mounted) return;
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
/// Get icon for current destination type
|
/// Get icon for current destination type
|
||||||
IconData _getDestinationIcon() {
|
IconData _getDestinationIcon() {
|
||||||
if (_destinationType ==
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
return Icons.all_inbox;
|
||||||
|
} else if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel) {
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
return Icons.public;
|
return Icons.public;
|
||||||
} else if (_destinationType ==
|
} else if (_destinationType ==
|
||||||
@@ -318,6 +399,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getDestinationLabel() {
|
String _getDestinationLabel() {
|
||||||
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
return AppLocalizations.of(context)!.showAll;
|
||||||
|
}
|
||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel &&
|
MessageDestinationPreferences.destinationTypeChannel &&
|
||||||
_selectedRecipient != null) {
|
_selectedRecipient != null) {
|
||||||
@@ -343,6 +427,12 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ToastLogger.error(context, 'Select a channel, contact, or room first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check destination type and send accordingly
|
// Check destination type and send accordingly
|
||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
@@ -375,6 +465,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
|
|
||||||
_textController.clear();
|
_textController.clear();
|
||||||
_focusNode.unfocus();
|
_focusNode.unfocus();
|
||||||
|
_markCurrentDestinationAsRead();
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -575,9 +666,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
if (_destinationType ==
|
if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeContact &&
|
MessageDestinationPreferences.destinationTypeContact &&
|
||||||
_selectedRecipient != null &&
|
_selectedRecipient != null &&
|
||||||
_selectedRecipient!.outPathLen >= 0) {
|
_selectedRecipient!.routeHasPath) {
|
||||||
imageDataBytesPerFragment = safeImageDataBytesForPath(
|
imageDataBytesPerFragment = safeImageDataBytesForPath(
|
||||||
_selectedRecipient!.outPathLen,
|
_selectedRecipient!.routeHopCount,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,11 +692,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
ToastLogger.error(context, 'Device key unavailable');
|
ToastLogger.error(context, 'Device key unavailable');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final senderKey6 = deviceKey
|
|
||||||
.sublist(0, 6)
|
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
||||||
.join();
|
|
||||||
|
|
||||||
final envelope = ImageEnvelope(
|
final envelope = ImageEnvelope(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
format: ImageFormat.avif,
|
format: ImageFormat.avif,
|
||||||
@@ -613,8 +699,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
width: result.width,
|
width: result.width,
|
||||||
height: result.height,
|
height: result.height,
|
||||||
sizeBytes: compressed.length,
|
sizeBytes: compressed.length,
|
||||||
senderKey6: senderKey6,
|
|
||||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -915,9 +999,6 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final senderKey6 = senderPublicKeyPrefix
|
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
|
||||||
.join('');
|
|
||||||
final durationMs = encodedPackets.fold<int>(
|
final durationMs = encodedPackets.fold<int>(
|
||||||
0,
|
0,
|
||||||
(sum, p) => sum + p.durationMs,
|
(sum, p) => sum + p.durationMs,
|
||||||
@@ -927,9 +1008,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
mode: mode,
|
mode: mode,
|
||||||
total: encodedPackets.length,
|
total: encodedPackets.length,
|
||||||
durationMs: durationMs,
|
durationMs: durationMs,
|
||||||
senderKey6: senderKey6,
|
version: 3,
|
||||||
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
||||||
version: 1,
|
|
||||||
);
|
);
|
||||||
final envelopeText = envelope.encodeText();
|
final envelopeText = envelope.encodeText();
|
||||||
|
|
||||||
@@ -1119,6 +1198,15 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.search),
|
||||||
|
title: const Text('Search messages'),
|
||||||
|
onTap: () async {
|
||||||
|
await _runAfterSheetDismissal(sheetContext, () async {
|
||||||
|
_showFilteredMessageSearch();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.add_location_alt),
|
leading: const Icon(Icons.add_location_alt),
|
||||||
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
title: Text(AppLocalizations.of(context)!.sendSarMarker),
|
||||||
@@ -1186,6 +1274,116 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showFilteredMessageSearch() {
|
||||||
|
final messagesProvider = context.read<MessagesProvider>();
|
||||||
|
final scopedMessages = _getFilteredMessages(messagesProvider);
|
||||||
|
final searchController = TextEditingController();
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setModalState) {
|
||||||
|
final query = searchController.text.trim().toLowerCase();
|
||||||
|
final matches = query.isEmpty
|
||||||
|
? scopedMessages
|
||||||
|
: scopedMessages.where((message) {
|
||||||
|
return message.text.toLowerCase().contains(query) ||
|
||||||
|
(message.senderName?.toLowerCase().contains(query) ??
|
||||||
|
false);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
height: MediaQuery.of(sheetContext).size.height * 0.72,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||||
|
child: TextField(
|
||||||
|
controller: searchController,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search in current filter',
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
suffixIcon: searchController.text.isEmpty
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
searchController.clear();
|
||||||
|
setModalState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onChanged: (_) => setModalState(() {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: matches.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
query.isEmpty
|
||||||
|
? 'No messages in this filter'
|
||||||
|
: 'No matches in this filter',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.separated(
|
||||||
|
itemCount: matches.length,
|
||||||
|
separatorBuilder: (_, _) =>
|
||||||
|
const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final message = matches[index];
|
||||||
|
final title =
|
||||||
|
message.senderName?.trim().isNotEmpty ==
|
||||||
|
true
|
||||||
|
? message.senderName!
|
||||||
|
: message.isSentMessage
|
||||||
|
? 'You'
|
||||||
|
: _getDestinationLabel();
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
message.text,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
_scrollToMessage(message.id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).whenComplete(searchController.dispose);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildDestinationAvatar(BuildContext context) {
|
Widget _buildDestinationAvatar(BuildContext context) {
|
||||||
final recipient = _selectedRecipient;
|
final recipient = _selectedRecipient;
|
||||||
if (recipient != null) {
|
if (recipient != null) {
|
||||||
@@ -1444,28 +1642,32 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _markCurrentDestinationAsRead() {
|
||||||
|
context.read<MessagesProvider>().markDestinationAsRead(
|
||||||
|
destinationType: _destinationType,
|
||||||
|
contact: _selectedRecipient,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
|
List<Message> _getFilteredMessages(MessagesProvider messagesProvider) {
|
||||||
// Get all recent messages
|
// Get all recent messages
|
||||||
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
final allMessages = messagesProvider.getRecentMessages(count: 100);
|
||||||
|
|
||||||
// Get simple mode setting from AppProvider
|
|
||||||
final appProvider = context.read<AppProvider>();
|
|
||||||
final isSimpleMode = appProvider.isSimpleMode;
|
|
||||||
|
|
||||||
List<Message> filteredMessages;
|
List<Message> filteredMessages;
|
||||||
|
|
||||||
// If channel destination is selected, filter by selected channel.
|
// If channel destination is selected, filter by selected channel.
|
||||||
if (_destinationType ==
|
if (_destinationType == MessageDestinationPreferences.destinationTypeAll) {
|
||||||
|
filteredMessages = allMessages;
|
||||||
|
} else if (_destinationType ==
|
||||||
MessageDestinationPreferences.destinationTypeChannel) {
|
MessageDestinationPreferences.destinationTypeChannel) {
|
||||||
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
final selectedChannelIdx = _selectedRecipient?.publicKey[1] ?? 0;
|
||||||
if (selectedChannelIdx == 0) {
|
filteredMessages = allMessages
|
||||||
// Public channel view keeps showing all messages (current app behavior).
|
.where(
|
||||||
filteredMessages = allMessages;
|
(message) =>
|
||||||
} else {
|
message.isChannelMessage &&
|
||||||
filteredMessages = allMessages
|
(message.channelIdx ?? 0) == selectedChannelIdx,
|
||||||
.where((message) => message.channelIdx == selectedChannelIdx)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// If a contact or room is selected, filter by recipient/sender prefixes.
|
// If a contact or room is selected, filter by recipient/sender prefixes.
|
||||||
else if ((_destinationType ==
|
else if ((_destinationType ==
|
||||||
@@ -1474,6 +1676,10 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
MessageDestinationPreferences.destinationTypeRoom) &&
|
MessageDestinationPreferences.destinationTypeRoom) &&
|
||||||
_selectedRecipient != null) {
|
_selectedRecipient != null) {
|
||||||
filteredMessages = allMessages.where((message) {
|
filteredMessages = allMessages.where((message) {
|
||||||
|
if (!message.isContactMessage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Include messages sent TO this recipient
|
// Include messages sent TO this recipient
|
||||||
if (message.recipientPublicKey != null &&
|
if (message.recipientPublicKey != null &&
|
||||||
message.recipientPublicKey!.length >= 6 &&
|
message.recipientPublicKey!.length >= 6 &&
|
||||||
@@ -1504,14 +1710,9 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
filteredMessages = allMessages;
|
filteredMessages = allMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In simple mode, filter out system messages (toast logs)
|
return filteredMessages
|
||||||
if (isSimpleMode) {
|
.where((message) => !message.isSystemMessage)
|
||||||
filteredMessages = filteredMessages
|
.toList();
|
||||||
.where((message) => !message.isSystemMessage)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredMessages;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleMessageTap(Message message) {
|
void _handleMessageTap(Message message) {
|
||||||
@@ -1559,6 +1760,7 @@ class _MessagesTabState extends State<MessagesTab> {
|
|||||||
onRefresh: _handleRefresh,
|
onRefresh: _handleRefresh,
|
||||||
onNavigateToMap: widget.onNavigateToMap,
|
onNavigateToMap: widget.onNavigateToMap,
|
||||||
onMessageTap: _handleMessageTap,
|
onMessageTap: _handleMessageTap,
|
||||||
|
onReplyToMessage: _replyToMessage,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import 'package:meshcore_client/meshcore_client.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
import '../services/route_hash_preferences.dart';
|
||||||
import '../utils/log_rx_route_decoder.dart';
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
class PacketLogScreen extends StatefulWidget {
|
class PacketLogScreen extends StatefulWidget {
|
||||||
@@ -460,26 +462,6 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
final isRx = log.direction == PacketDirection.rx;
|
final isRx = log.direction == PacketDirection.rx;
|
||||||
final directionColor = isRx ? Colors.green : Colors.blue;
|
final directionColor = isRx ? Colors.green : Colors.blue;
|
||||||
final rxInfo = log.logRxDataInfo;
|
final rxInfo = log.logRxDataInfo;
|
||||||
final contacts = context.watch<ContactsProvider>().contacts;
|
|
||||||
final connectionProvider = context.watch<ConnectionProvider>();
|
|
||||||
final decodedRoute = LogRxRouteDecoder.decode(log.rawData);
|
|
||||||
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
|
|
||||||
final ownName =
|
|
||||||
connectionProvider.deviceInfo.selfName ??
|
|
||||||
connectionProvider.deviceInfo.displayName;
|
|
||||||
final resolvedPath = decodedRoute?.pathHashes
|
|
||||||
.map(
|
|
||||||
(hash) => LogRxRouteDecoder.resolveHash(
|
|
||||||
hash,
|
|
||||||
contacts: contacts,
|
|
||||||
ownPublicKey: ownPublicKey,
|
|
||||||
ownName: ownName,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
final originalSender = resolvedPath != null && resolvedPath.isNotEmpty
|
|
||||||
? resolvedPath.first
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
@@ -604,13 +586,9 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (isRx && decodedRoute != null) ...[
|
if (isRx) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_RouteSection(
|
_DecodedRouteSection(log: log),
|
||||||
route: decodedRoute,
|
|
||||||
path: resolvedPath ?? const [],
|
|
||||||
originalSender: originalSender,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
@@ -755,6 +733,97 @@ 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 {
|
class _RouteSection extends StatelessWidget {
|
||||||
final DecodedLogRxRoute route;
|
final DecodedLogRxRoute route;
|
||||||
final List<ResolvedNodeHash> path;
|
final List<ResolvedNodeHash> path;
|
||||||
@@ -802,7 +871,13 @@ class _RouteSection extends StatelessWidget {
|
|||||||
_FactCard(
|
_FactCard(
|
||||||
icon: Icons.hub,
|
icon: Icons.hub,
|
||||||
label: 'Hops',
|
label: 'Hops',
|
||||||
value: '${route.pathHashes.length}',
|
value: '${route.hopCount}',
|
||||||
|
),
|
||||||
|
_FactCard(
|
||||||
|
icon: Icons.tag,
|
||||||
|
label: 'Hash size',
|
||||||
|
value:
|
||||||
|
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
|
||||||
),
|
),
|
||||||
if (originalSender != null)
|
if (originalSender != null)
|
||||||
_FactCard(
|
_FactCard(
|
||||||
|
|||||||
1115
lib/screens/sensors_tab.dart
Normal file
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
@@ -15,7 +15,7 @@ class WelcomeWizardScreen extends StatefulWidget {
|
|||||||
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||||
final PageController _pageController = PageController();
|
final PageController _pageController = PageController();
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
static const int _totalPages = 6;
|
static const int _totalPages = 5;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -99,7 +99,6 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_buildWelcomePage(context, l10n, colorScheme),
|
_buildWelcomePage(context, l10n, colorScheme),
|
||||||
_buildConnectingPage(context, l10n, colorScheme),
|
_buildConnectingPage(context, l10n, colorScheme),
|
||||||
_buildSimpleModePage(context, l10n, colorScheme),
|
|
||||||
_buildChannelPage(context, l10n, colorScheme),
|
_buildChannelPage(context, l10n, colorScheme),
|
||||||
_buildContactsPage(context, l10n, colorScheme),
|
_buildContactsPage(context, l10n, colorScheme),
|
||||||
_buildMapPage(context, l10n, colorScheme),
|
_buildMapPage(context, l10n, colorScheme),
|
||||||
@@ -182,42 +181,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
title: l10n.wizardConnectingTitle,
|
title: l10n.wizardConnectingTitle,
|
||||||
description: l10n.wizardConnectingDescription,
|
description: l10n.wizardConnectingDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.radio, text: l10n.wizardConnectingFeature1),
|
||||||
icon: Icons.radio,
|
_FeatureItem(icon: Icons.link, text: l10n.wizardConnectingFeature2),
|
||||||
text: l10n.wizardConnectingFeature1,
|
_FeatureItem(icon: Icons.wifi_off, text: l10n.wizardConnectingFeature3),
|
||||||
),
|
|
||||||
_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,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -234,18 +200,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
title: l10n.wizardChannelTitle,
|
title: l10n.wizardChannelTitle,
|
||||||
description: l10n.wizardChannelDescription,
|
description: l10n.wizardChannelDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.public, text: l10n.wizardChannelFeature1),
|
||||||
icon: Icons.public,
|
_FeatureItem(icon: Icons.groups, text: l10n.wizardChannelFeature2),
|
||||||
text: l10n.wizardChannelFeature1,
|
_FeatureItem(icon: Icons.send, text: l10n.wizardChannelFeature3),
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.groups,
|
|
||||||
text: l10n.wizardChannelFeature2,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.send,
|
|
||||||
text: l10n.wizardChannelFeature3,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -262,14 +219,8 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
title: l10n.wizardContactsTitle,
|
title: l10n.wizardContactsTitle,
|
||||||
description: l10n.wizardContactsDescription,
|
description: l10n.wizardContactsDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.person_add, text: l10n.wizardContactsFeature1),
|
||||||
icon: Icons.person_add,
|
_FeatureItem(icon: Icons.chat, text: l10n.wizardContactsFeature2),
|
||||||
text: l10n.wizardContactsFeature1,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.chat,
|
|
||||||
text: l10n.wizardContactsFeature2,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
_FeatureItem(
|
||||||
icon: Icons.battery_std,
|
icon: Icons.battery_std,
|
||||||
text: l10n.wizardContactsFeature3,
|
text: l10n.wizardContactsFeature3,
|
||||||
@@ -290,22 +241,13 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
title: l10n.wizardMapTitle,
|
title: l10n.wizardMapTitle,
|
||||||
description: l10n.wizardMapDescription,
|
description: l10n.wizardMapDescription,
|
||||||
features: [
|
features: [
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapFeature1),
|
||||||
icon: Icons.location_on,
|
|
||||||
text: l10n.wizardMapFeature1,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
_FeatureItem(
|
||||||
icon: Icons.person_pin_circle,
|
icon: Icons.person_pin_circle,
|
||||||
text: l10n.wizardMapFeature2,
|
text: l10n.wizardMapFeature2,
|
||||||
),
|
),
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.offline_pin, text: l10n.wizardMapFeature3),
|
||||||
icon: Icons.offline_pin,
|
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapFeature4),
|
||||||
text: l10n.wizardMapFeature3,
|
|
||||||
),
|
|
||||||
_FeatureItem(
|
|
||||||
icon: Icons.draw,
|
|
||||||
text: l10n.wizardMapFeature4,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -332,20 +274,16 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
color: iconColor.withValues(alpha: 0.1),
|
color: iconColor.withValues(alpha: 0.1),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(icon, size: 80, color: iconColor),
|
||||||
icon,
|
|
||||||
size: 80,
|
|
||||||
color: iconColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
// Title
|
// Title
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: colorScheme.onSurface,
|
color: colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -353,36 +291,33 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
Text(
|
Text(
|
||||||
description,
|
description,
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
if (features != null && features.isNotEmpty) ...[
|
if (features != null && features.isNotEmpty) ...[
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
// Features list
|
// Features list
|
||||||
...features.map((feature) => Padding(
|
...features.map(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
(feature) => Padding(
|
||||||
child: Row(
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
children: [
|
child: Row(
|
||||||
Icon(
|
children: [
|
||||||
feature.icon,
|
Icon(feature.icon, color: colorScheme.primary, size: 24),
|
||||||
color: colorScheme.primary,
|
const SizedBox(width: 16),
|
||||||
size: 24,
|
Expanded(
|
||||||
),
|
child: Text(
|
||||||
const SizedBox(width: 16),
|
feature.text,
|
||||||
Expanded(
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
child: Text(
|
color: colorScheme.onSurface,
|
||||||
feature.text,
|
|
||||||
style:
|
|
||||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
)),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
/// Service for accessing build information from native platform code
|
/// Service for accessing build information from native platform code
|
||||||
/// Currently supports Android only - returns "unknown" for other platforms
|
/// Currently supports Android only - returns "unknown" for other platforms
|
||||||
@@ -25,7 +24,7 @@ class BuildInfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only Android has the platform channel implementation
|
// Only Android has the platform channel implementation
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
||||||
_cachedCommitHash = 'unknown';
|
_cachedCommitHash = 'unknown';
|
||||||
return _cachedCommitHash!;
|
return _cachedCommitHash!;
|
||||||
|
|||||||
15
lib/services/developer_mode_service.dart
Normal file
15
lib/services/developer_mode_service.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@ class LocalePreferences {
|
|||||||
Locale('fr'), // French
|
Locale('fr'), // French
|
||||||
Locale('it'), // Italian
|
Locale('it'), // Italian
|
||||||
Locale('el'), // Greek
|
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
|
/// Get the saved locale or return null to use system locale
|
||||||
@@ -64,7 +70,17 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Greek';
|
return 'Greek';
|
||||||
case 'cn':
|
case 'pt':
|
||||||
|
return 'Portuguese';
|
||||||
|
case 'tr':
|
||||||
|
return 'Turkish';
|
||||||
|
case 'pl':
|
||||||
|
return 'Polish';
|
||||||
|
case 'uk':
|
||||||
|
return 'Ukrainian';
|
||||||
|
case 'ru':
|
||||||
|
return 'Русский';
|
||||||
|
case 'zh':
|
||||||
return '简体中文';
|
return '简体中文';
|
||||||
default:
|
default:
|
||||||
return locale.languageCode;
|
return locale.languageCode;
|
||||||
@@ -90,7 +106,17 @@ class LocalePreferences {
|
|||||||
return 'Italiano';
|
return 'Italiano';
|
||||||
case 'el':
|
case 'el':
|
||||||
return 'Ελληνικά';
|
return 'Ελληνικά';
|
||||||
case 'cn':
|
case 'pt':
|
||||||
|
return 'Português';
|
||||||
|
case 'tr':
|
||||||
|
return 'Türkçe';
|
||||||
|
case 'pl':
|
||||||
|
return 'Polski';
|
||||||
|
case 'uk':
|
||||||
|
return 'Українська';
|
||||||
|
case 'ru':
|
||||||
|
return 'Русский';
|
||||||
|
case 'zh':
|
||||||
return '简体中文';
|
return '简体中文';
|
||||||
default:
|
default:
|
||||||
return locale.languageCode;
|
return locale.languageCode;
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ class LocationTrackingService {
|
|||||||
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
|
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
|
||||||
static const String _prefKeyLastLat = 'background_last_lat';
|
static const String _prefKeyLastLat = 'background_last_lat';
|
||||||
static const String _prefKeyLastLon = 'background_last_lon';
|
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
|
// Configuration Properties
|
||||||
@@ -58,6 +64,15 @@ class LocationTrackingService {
|
|||||||
/// GPS update distance filter for position stream
|
/// GPS update distance filter for position stream
|
||||||
double gpsUpdateDistance = 10.0;
|
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
|
// State Properties
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -84,6 +99,11 @@ class LocationTrackingService {
|
|||||||
/// Position stream subscription
|
/// Position stream subscription
|
||||||
StreamSubscription<Position>? _positionSubscription;
|
StreamSubscription<Position>? _positionSubscription;
|
||||||
|
|
||||||
|
Timer? _fastLocationTimer;
|
||||||
|
bool _isFastLocationActiveUse = false;
|
||||||
|
DateTime? _lastFastLocationSentAt;
|
||||||
|
Position? _lastFastLocationSentPosition;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Callback Properties
|
// Callback Properties
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -100,6 +120,9 @@ class LocationTrackingService {
|
|||||||
/// Called when tracking state changes
|
/// Called when tracking state changes
|
||||||
void Function(bool isTracking)? onTrackingStateChanged;
|
void Function(bool isTracking)? onTrackingStateChanged;
|
||||||
|
|
||||||
|
/// Called when a fast private GPS update should be sent
|
||||||
|
void Function(Position position, String reason)? onFastLocationUpdate;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Initialization
|
// Initialization
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -178,7 +201,9 @@ class LocationTrackingService {
|
|||||||
for (int attempt = 0; attempt <= retryCount; attempt++) {
|
for (int attempt = 0; attempt <= retryCount; attempt++) {
|
||||||
try {
|
try {
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount');
|
debugPrint(
|
||||||
|
'🔄 [LocationTracking] Retry attempt $attempt/$retryCount',
|
||||||
|
);
|
||||||
// Exponential backoff: wait 2^attempt seconds before retry
|
// Exponential backoff: wait 2^attempt seconds before retry
|
||||||
await Future.delayed(Duration(seconds: 1 << attempt));
|
await Future.delayed(Duration(seconds: 1 << attempt));
|
||||||
}
|
}
|
||||||
@@ -192,21 +217,29 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
currentPosition = position;
|
currentPosition = position;
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries');
|
debugPrint(
|
||||||
|
'✅ [LocationTracking] Position acquired after $attempt retries',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return position;
|
return position;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final isLastAttempt = attempt == retryCount;
|
final isLastAttempt = attempt == retryCount;
|
||||||
if (isLastAttempt) {
|
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
|
// Only call error callback on final failure, and make it user-friendly
|
||||||
if (e.toString().contains('TimeoutException')) {
|
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 {
|
} else {
|
||||||
onError?.call('Failed to get GPS position. Check device settings.');
|
onError?.call('Failed to get GPS position. Check device settings.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e');
|
debugPrint(
|
||||||
|
'⚠️ [LocationTracking] Position attempt $attempt failed: $e',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLastAttempt) {
|
if (isLastAttempt) {
|
||||||
@@ -244,16 +277,16 @@ class LocationTrackingService {
|
|||||||
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
|
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
|
||||||
Future<bool> startTracking({double? distanceThreshold}) async {
|
Future<bool> startTracking({double? distanceThreshold}) async {
|
||||||
if (!_isInitialized) {
|
if (!_isInitialized) {
|
||||||
debugPrint(
|
debugPrint('⚠️ [LocationTracking] Service not initialized');
|
||||||
'⚠️ [LocationTracking] Service not initialized',
|
|
||||||
);
|
|
||||||
onError?.call('Location tracking service not initialized');
|
onError?.call('Location tracking service not initialized');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow tracking without BLE connection - broadcasts will be skipped
|
// Allow tracking without BLE connection - broadcasts will be skipped
|
||||||
if (_bleService == null || !_bleService!.isConnected) {
|
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
|
// Check permissions
|
||||||
@@ -271,17 +304,20 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
// Try to get initial position in background (non-blocking)
|
// Try to get initial position in background (non-blocking)
|
||||||
// This will populate currentPosition but won't block tracking startup
|
// This will populate currentPosition but won't block tracking startup
|
||||||
getCurrentPosition(
|
getCurrentPosition(timeLimit: const Duration(seconds: 10), retryCount: 1)
|
||||||
timeLimit: const Duration(seconds: 10),
|
.then((position) {
|
||||||
retryCount: 1,
|
if (position != null) {
|
||||||
).then((position) {
|
debugPrint(
|
||||||
if (position != null) {
|
'✅ [LocationTracking] Initial position acquired in background',
|
||||||
debugPrint('✅ [LocationTracking] Initial position acquired in background');
|
);
|
||||||
}
|
}
|
||||||
}).catchError((error) {
|
})
|
||||||
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error');
|
.catchError((error) {
|
||||||
// Not critical - position stream will eventually provide position
|
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)
|
// Start position stream immediately (don't wait for initial position)
|
||||||
try {
|
try {
|
||||||
@@ -296,6 +332,7 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
isTracking = true;
|
isTracking = true;
|
||||||
onTrackingStateChanged?.call(true);
|
onTrackingStateChanged?.call(true);
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
|
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
|
||||||
@@ -318,6 +355,7 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
isTracking = false;
|
isTracking = false;
|
||||||
onTrackingStateChanged?.call(false);
|
onTrackingStateChanged?.call(false);
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
|
||||||
// Reset first position flag so next connection starts fresh
|
// Reset first position flag so next connection starts fresh
|
||||||
_firstPositionSet = false;
|
_firstPositionSet = false;
|
||||||
@@ -361,6 +399,8 @@ class LocationTrackingService {
|
|||||||
// Notify listeners
|
// Notify listeners
|
||||||
onPositionUpdate?.call(position);
|
onPositionUpdate?.call(position);
|
||||||
|
|
||||||
|
_evaluateFastLocationMovement(position);
|
||||||
|
|
||||||
// SPECIAL CASE: First stable position after connection
|
// SPECIAL CASE: First stable position after connection
|
||||||
// Set lat/lon on device WITHOUT broadcasting to mesh network
|
// Set lat/lon on device WITHOUT broadcasting to mesh network
|
||||||
if (!_firstPositionSet) {
|
if (!_firstPositionSet) {
|
||||||
@@ -378,12 +418,16 @@ class LocationTrackingService {
|
|||||||
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
|
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
|
||||||
void _setInitialPosition(Position position) async {
|
void _setInitialPosition(Position position) async {
|
||||||
if (_bleService == null || !_bleService!.isConnected) {
|
if (_bleService == null || !_bleService!.isConnected) {
|
||||||
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected');
|
debugPrint(
|
||||||
|
'⚠️ [LocationTracking] Cannot set initial position: BLE not connected',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)');
|
debugPrint(
|
||||||
|
'📍 [LocationTracking] Setting initial position (no broadcast)',
|
||||||
|
);
|
||||||
|
|
||||||
// Update device's advertised location WITHOUT sending advertisement
|
// Update device's advertised location WITHOUT sending advertisement
|
||||||
await _bleService!.setAdvertLatLon(
|
await _bleService!.setAdvertLatLon(
|
||||||
@@ -414,7 +458,94 @@ class LocationTrackingService {
|
|||||||
void _checkAndBroadcast(Position position) {
|
void _checkAndBroadcast(Position position) {
|
||||||
// Automatic broadcasting disabled
|
// Automatic broadcasting disabled
|
||||||
// Use the manual advert button instead
|
// 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);
|
await _bleService!.sendSelfAdvert(floodMode: true);
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Manual broadcast successful');
|
debugPrint('✅ [LocationTracking] Manual broadcast successful');
|
||||||
debugPrint(' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s');
|
debugPrint(
|
||||||
|
' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s',
|
||||||
|
);
|
||||||
onBroadcastSent?.call(position);
|
onBroadcastSent?.call(position);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -480,12 +613,24 @@ class LocationTrackingService {
|
|||||||
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
|
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
|
||||||
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
|
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
|
||||||
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
|
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('✅ [LocationTracking] Settings loaded');
|
||||||
debugPrint(' Min distance: ${minDistanceMeters}m');
|
debugPrint(' Min distance: ${minDistanceMeters}m');
|
||||||
debugPrint(' Max distance: ${maxDistanceMeters}m');
|
debugPrint(' Max distance: ${maxDistanceMeters}m');
|
||||||
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
|
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
|
||||||
debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
|
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
|
/// Save settings to SharedPreferences
|
||||||
@@ -497,6 +642,18 @@ class LocationTrackingService {
|
|||||||
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
|
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
|
||||||
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
|
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
|
||||||
await prefs.setBool(_prefKeyEnabled, isTracking);
|
await prefs.setBool(_prefKeyEnabled, isTracking);
|
||||||
|
await prefs.setBool(
|
||||||
|
_prefKeyFastLocationEnabled,
|
||||||
|
fastLocationUpdatesEnabled,
|
||||||
|
);
|
||||||
|
await prefs.setDouble(
|
||||||
|
_prefKeyFastMovementThreshold,
|
||||||
|
fastLocationMovementThresholdMeters,
|
||||||
|
);
|
||||||
|
await prefs.setInt(
|
||||||
|
_prefKeyFastActiveCadence,
|
||||||
|
fastLocationActiveCadenceSeconds,
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Settings saved');
|
debugPrint('✅ [LocationTracking] Settings saved');
|
||||||
}
|
}
|
||||||
@@ -509,6 +666,7 @@ class LocationTrackingService {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
debugPrint('🗑️ [LocationTracking] Disposing service');
|
debugPrint('🗑️ [LocationTracking] Disposing service');
|
||||||
_positionSubscription?.cancel();
|
_positionSubscription?.cancel();
|
||||||
|
_fastLocationTimer?.cancel();
|
||||||
_positionSubscription = null;
|
_positionSubscription = null;
|
||||||
_bleService = null;
|
_bleService = null;
|
||||||
_isInitialized = false;
|
_isInitialized = false;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
|
|||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../models/sar_marker.dart';
|
import '../models/sar_marker.dart';
|
||||||
|
import '../widgets/common/contact_avatar.dart';
|
||||||
import '../widgets/map/location_pointer.dart';
|
import '../widgets/map/location_pointer.dart';
|
||||||
|
|
||||||
/// Centralized service for map marker management.
|
/// Centralized service for map marker management.
|
||||||
@@ -77,9 +78,15 @@ class MapMarkerService {
|
|||||||
// Marker icon
|
// Marker icon
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: getContactMarkerColor(contact, context),
|
color: Colors.white,
|
||||||
shape: BoxShape.circle,
|
shape: contact.type == ContactType.channel ||
|
||||||
border: Border.all(color: Colors.white, width: 2),
|
contact.type == ContactType.room
|
||||||
|
? BoxShape.rectangle
|
||||||
|
: BoxShape.circle,
|
||||||
|
borderRadius: contact.type == ContactType.channel ||
|
||||||
|
contact.type == ContactType.room
|
||||||
|
? BorderRadius.circular(14)
|
||||||
|
: null,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
@@ -88,17 +95,8 @@ class MapMarkerService {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(2),
|
||||||
child: contact.roleEmoji != null
|
child: ContactAvatar(contact: contact, radius: 16),
|
||||||
? Text(
|
|
||||||
contact.roleEmoji!,
|
|
||||||
style: const TextStyle(fontSize: 18),
|
|
||||||
)
|
|
||||||
: Icon(
|
|
||||||
getContactMarkerIcon(contact),
|
|
||||||
color: Colors.white,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
// Name label (without emoji)
|
// Name label (without emoji)
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class MeshMapNode {
|
class MeshMapNode {
|
||||||
final int type;
|
final int type;
|
||||||
@@ -33,26 +35,37 @@ class MeshMapNode {
|
|||||||
class MeshMapNodesService {
|
class MeshMapNodesService {
|
||||||
static const String _nodesEndpoint =
|
static const String _nodesEndpoint =
|
||||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||||
static const Duration _cacheTtl = Duration(minutes: 2);
|
static const Duration _cacheTtl = Duration(hours: 24);
|
||||||
static const Duration traceCacheTtl = Duration(minutes: 10);
|
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 List<MeshMapNode>? _cachedNodes;
|
||||||
static DateTime? _cachedAt;
|
static DateTime? _cachedAt;
|
||||||
|
static Future<void>? _ongoingSync;
|
||||||
|
|
||||||
static Future<List<MeshMapNode>> fetchNodes({
|
static Future<List<MeshMapNode>> fetchNodes({
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
Duration cacheTtl = _cacheTtl,
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
bool allowNetwork = true,
|
||||||
|
http.Client? client,
|
||||||
}) async {
|
}) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
_cachedNodes != null &&
|
cached.isNotEmpty &&
|
||||||
_cachedAt != null &&
|
_cachedAt != null &&
|
||||||
now.difference(_cachedAt!) < cacheTtl) {
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
return _cachedNodes!;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await http
|
if (!allowNetwork) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await (client ?? http.Client())
|
||||||
.get(Uri.parse(_nodesEndpoint))
|
.get(Uri.parse(_nodesEndpoint))
|
||||||
.timeout(const Duration(seconds: 12));
|
.timeout(traceTimeout);
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
throw Exception('Map nodes API returned ${response.statusCode}');
|
throw Exception('Map nodes API returned ${response.statusCode}');
|
||||||
}
|
}
|
||||||
@@ -68,8 +81,131 @@ class MeshMapNodesService {
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
_cachedNodes = nodes;
|
await _storeCache(nodes, cachedAt: now);
|
||||||
_cachedAt = now;
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<MeshMapNode>> loadCachedNodes({
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_cachedNodes != null &&
|
||||||
|
_cachedAt != null &&
|
||||||
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
|
return _cachedNodes!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||||
|
final cachedJson = prefs.getString(_cacheKey);
|
||||||
|
if (cachedAtMs == null || cachedJson == null) {
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = null;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||||
|
if (now.difference(cachedAt) >= cacheTtl) {
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(cachedJson) as List<dynamic>;
|
||||||
|
final nodes = decoded
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(MeshMapNode.fromJson)
|
||||||
|
.where(
|
||||||
|
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
_cachedNodes = nodes;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
|
||||||
|
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
|
return nodes.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> syncInBackgroundIfStale({
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
http.Client? client,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
|
if (cached.isNotEmpty && _cachedAt != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_ongoingSync != null) {
|
||||||
|
return _ongoingSync!;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ongoingSync = () async {
|
||||||
|
try {
|
||||||
|
await fetchNodes(
|
||||||
|
forceRefresh: true,
|
||||||
|
cacheTtl: cacheTtl,
|
||||||
|
allowNetwork: true,
|
||||||
|
client: client,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Background refresh is best-effort only.
|
||||||
|
} finally {
|
||||||
|
_ongoingSync = null;
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
|
||||||
|
return _ongoingSync!;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clearCache() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_cacheKey);
|
||||||
|
await prefs.remove(_cacheTimestampKey);
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<DateTime?> cachedAt() async {
|
||||||
|
if (_cachedAt != null) return _cachedAt;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||||
|
if (cachedAtMs == null) return null;
|
||||||
|
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||||
|
return _cachedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
static 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class MessageDestinationPreferences {
|
|||||||
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
|
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
|
||||||
|
|
||||||
/// Destination types
|
/// Destination types
|
||||||
|
static const String destinationTypeAll = 'all';
|
||||||
static const String destinationTypeChannel = 'channel';
|
static const String destinationTypeChannel = 'channel';
|
||||||
static const String destinationTypeContact = 'contact';
|
static const String destinationTypeContact = 'contact';
|
||||||
static const String destinationTypeRoom = 'room';
|
static const String destinationTypeRoom = 'room';
|
||||||
@@ -24,14 +25,11 @@ class MessageDestinationPreferences {
|
|||||||
|
|
||||||
final publicKey = prefs.getString(_recipientPublicKeyKey);
|
final publicKey = prefs.getString(_recipientPublicKeyKey);
|
||||||
|
|
||||||
return {
|
return {'type': type, 'publicKey': ?publicKey};
|
||||||
'type': type,
|
|
||||||
'publicKey': ?publicKey,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save the selected destination
|
/// 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)
|
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
|
||||||
static Future<void> setDestination(
|
static Future<void> setDestination(
|
||||||
String type, {
|
String type, {
|
||||||
@@ -58,6 +56,8 @@ class MessageDestinationPreferences {
|
|||||||
/// Get display name for destination type
|
/// Get display name for destination type
|
||||||
static String getDestinationTypeName(String type) {
|
static String getDestinationTypeName(String type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case destinationTypeAll:
|
||||||
|
return 'All';
|
||||||
case destinationTypeChannel:
|
case destinationTypeChannel:
|
||||||
return 'Channel';
|
return 'Channel';
|
||||||
case destinationTypeContact:
|
case destinationTypeContact:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../models/message_contact_location.dart';
|
import '../models/message_contact_location.dart';
|
||||||
import '../models/message_reception_details.dart';
|
import '../models/message_reception_details.dart';
|
||||||
|
import '../models/message_transfer_details.dart';
|
||||||
|
import '../models/message_route_metadata.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting messages to local storage
|
/// Service for persisting messages to local storage
|
||||||
@@ -13,6 +15,10 @@ class MessageStorageService {
|
|||||||
'stored_message_contact_locations';
|
'stored_message_contact_locations';
|
||||||
static const String _messageReceptionDetailsKey =
|
static const String _messageReceptionDetailsKey =
|
||||||
'stored_message_reception_details';
|
'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
|
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||||
|
|
||||||
/// Save messages to persistent storage
|
/// Save messages to persistent storage
|
||||||
@@ -20,6 +26,8 @@ class MessageStorageService {
|
|||||||
List<Message> messages, {
|
List<Message> messages, {
|
||||||
Map<String, MessageContactLocation> messageContactLocations = const {},
|
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||||
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
||||||
|
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
||||||
|
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -39,6 +47,8 @@ class MessageStorageService {
|
|||||||
.toSet();
|
.toSet();
|
||||||
final locationJson = <String, dynamic>{};
|
final locationJson = <String, dynamic>{};
|
||||||
final receptionJson = <String, dynamic>{};
|
final receptionJson = <String, dynamic>{};
|
||||||
|
final transferJson = <String, dynamic>{};
|
||||||
|
final routeMetadataJson = <String, dynamic>{};
|
||||||
for (final entry in messageContactLocations.entries) {
|
for (final entry in messageContactLocations.entries) {
|
||||||
if (retainedMessageIds.contains(entry.key)) {
|
if (retainedMessageIds.contains(entry.key)) {
|
||||||
locationJson[entry.key] = entry.value.toJson();
|
locationJson[entry.key] = entry.value.toJson();
|
||||||
@@ -49,6 +59,16 @@ class MessageStorageService {
|
|||||||
receptionJson[entry.key] = entry.value.toJson();
|
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(
|
await prefs.setString(
|
||||||
_messageContactLocationsKey,
|
_messageContactLocationsKey,
|
||||||
jsonEncode(locationJson),
|
jsonEncode(locationJson),
|
||||||
@@ -57,6 +77,14 @@ class MessageStorageService {
|
|||||||
_messageReceptionDetailsKey,
|
_messageReceptionDetailsKey,
|
||||||
jsonEncode(receptionJson),
|
jsonEncode(receptionJson),
|
||||||
);
|
);
|
||||||
|
await prefs.setString(
|
||||||
|
_messageTransferDetailsKey,
|
||||||
|
jsonEncode(transferJson),
|
||||||
|
);
|
||||||
|
await prefs.setString(
|
||||||
|
_messageRouteMetadataKey,
|
||||||
|
jsonEncode(routeMetadataJson),
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||||
@@ -126,6 +154,62 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
/// Load messages from persistent storage
|
||||||
Future<List<Message>> loadMessages() async {
|
Future<List<Message>> loadMessages() async {
|
||||||
try {
|
try {
|
||||||
@@ -161,6 +245,8 @@ class MessageStorageService {
|
|||||||
await prefs.remove(_messagesKey);
|
await prefs.remove(_messagesKey);
|
||||||
await prefs.remove(_messageContactLocationsKey);
|
await prefs.remove(_messageContactLocationsKey);
|
||||||
await prefs.remove(_messageReceptionDetailsKey);
|
await prefs.remove(_messageReceptionDetailsKey);
|
||||||
|
await prefs.remove(_messageTransferDetailsKey);
|
||||||
|
await prefs.remove(_messageRouteMetadataKey);
|
||||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||||
|
|||||||
46
lib/services/messaging_route_preferences.dart
Normal file
46
lib/services/messaging_route_preferences.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
lib/services/nearest_router_selector.dart
Normal file
69
lib/services/nearest_router_selector.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,6 @@ class NotificationService {
|
|||||||
requestAlertPermission: true,
|
requestAlertPermission: true,
|
||||||
requestBadgePermission: true,
|
requestBadgePermission: true,
|
||||||
requestSoundPermission: true,
|
requestSoundPermission: true,
|
||||||
requestCriticalPermission: true, // For urgent SAR notifications
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combined initialization settings
|
// Combined initialization settings
|
||||||
@@ -100,8 +99,6 @@ class NotificationService {
|
|||||||
alert: true,
|
alert: true,
|
||||||
badge: true,
|
badge: true,
|
||||||
sound: true,
|
sound: true,
|
||||||
critical:
|
|
||||||
true, // Request critical alert permission for urgent SAR notifications
|
|
||||||
);
|
);
|
||||||
_permissionGranted = granted ?? false;
|
_permissionGranted = granted ?? false;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -274,8 +271,7 @@ class NotificationService {
|
|||||||
badgeNumber: 1,
|
badgeNumber: 1,
|
||||||
threadIdentifier: 'sar_markers',
|
threadIdentifier: 'sar_markers',
|
||||||
categoryIdentifier: 'SAR_ALERT',
|
categoryIdentifier: 'SAR_ALERT',
|
||||||
interruptionLevel:
|
interruptionLevel: InterruptionLevel.timeSensitive,
|
||||||
InterruptionLevel.critical, // Critical alert (bypasses silent mode)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Combined notification details
|
// Combined notification details
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class PacketCaptureStorageService {
|
|||||||
File? _file;
|
File? _file;
|
||||||
|
|
||||||
Future<File> _resolveFile() async {
|
Future<File> _resolveFile() async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
throw UnsupportedError('Packet capture file storage is not supported on web');
|
||||||
|
}
|
||||||
if (_file != null) return _file!;
|
if (_file != null) return _file!;
|
||||||
final dir = await getApplicationSupportDirectory();
|
final dir = await getApplicationSupportDirectory();
|
||||||
final file = File('${dir.path}/$_fileName');
|
final file = File('${dir.path}/$_fileName');
|
||||||
@@ -73,6 +76,7 @@ class PacketCaptureStorageService {
|
|||||||
|
|
||||||
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
||||||
if (logs.isEmpty) return;
|
if (logs.isEmpty) return;
|
||||||
|
if (kIsWeb) return;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
final sink = file.openWrite(mode: FileMode.append);
|
final sink = file.openWrite(mode: FileMode.append);
|
||||||
@@ -98,6 +102,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
||||||
|
if (kIsWeb) return const [];
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (!await file.exists()) return const [];
|
if (!await file.exists()) return const [];
|
||||||
@@ -116,6 +121,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<int> count() async {
|
Future<int> count() async {
|
||||||
|
if (kIsWeb) return 0;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (!await file.exists()) return 0;
|
if (!await file.exists()) return 0;
|
||||||
@@ -127,6 +133,7 @@ class PacketCaptureStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
|
if (kIsWeb) return;
|
||||||
try {
|
try {
|
||||||
final file = await _resolveFile();
|
final file = await _resolveFile();
|
||||||
if (await file.exists()) {
|
if (await file.exists()) {
|
||||||
|
|||||||
273
lib/services/path_history_service.dart
Normal file
273
lib/services/path_history_service.dart
Normal 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();
|
||||||
|
}
|
||||||
26
lib/services/route_hash_preferences.dart
Normal file
26
lib/services/route_hash_preferences.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
class AvatarLabelHelper {
|
class AvatarLabelHelper {
|
||||||
|
static final RegExp _alnumChunks = RegExp(r'[A-Za-z0-9]+');
|
||||||
|
|
||||||
static String buildLabel(String name) {
|
static String buildLabel(String name) {
|
||||||
final trimmed = name.trim();
|
final trimmed = name.trim();
|
||||||
if (trimmed.isEmpty) return '?';
|
if (trimmed.isEmpty) return '?';
|
||||||
@@ -11,18 +13,23 @@ class AvatarLabelHelper {
|
|||||||
return '#${_take(hashBody, 2)}'.toUpperCase();
|
return '#${_take(hashBody, 2)}'.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
final parts = trimmed
|
final parts = _alnumChunks
|
||||||
.split(RegExp(r'[\s_-]+'))
|
.allMatches(trimmed)
|
||||||
|
.map((match) => match.group(0)!)
|
||||||
.where((part) => part.isNotEmpty)
|
.where((part) => part.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
if (parts.isEmpty) {
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
|
||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
final first = _take(parts[0], 1);
|
final first = _take(parts[0], 1);
|
||||||
final second = _take(parts[1], 1);
|
final second = _take(parts[1], 1);
|
||||||
return '$first$second'.toUpperCase();
|
return '$first$second'.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
return _take(trimmed, 2).toUpperCase();
|
return _take(parts.first, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _take(String value, int count) {
|
static String _take(String value, int count) {
|
||||||
|
|||||||
116
lib/utils/contact_grouping.dart
Normal file
116
lib/utils/contact_grouping.dart
Normal 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});
|
||||||
|
}
|
||||||
69
lib/utils/fast_gps_packet.dart
Normal file
69
lib/utils/fast_gps_packet.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE
|
|||||||
const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen
|
const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen
|
||||||
const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD
|
const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD
|
||||||
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/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 _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
|
||||||
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
|
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
|
||||||
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
|
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
|
||||||
@@ -31,7 +31,7 @@ enum ImageFormat {
|
|||||||
/// A single binary fragment of a compressed image.
|
/// A single binary fragment of a compressed image.
|
||||||
///
|
///
|
||||||
/// Binary format (direct contacts, via pushRawData / cmdSendRawData):
|
/// 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.
|
/// Legacy default is 152 data bytes per fragment.
|
||||||
class ImagePacket {
|
class ImagePacket {
|
||||||
@@ -50,7 +50,7 @@ class ImagePacket {
|
|||||||
});
|
});
|
||||||
|
|
||||||
static const int _magic = 0x49; // 'I'
|
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 =
|
static const int maxDataBytes =
|
||||||
152; // Conservative default for compatibility.
|
152; // Conservative default for compatibility.
|
||||||
|
|
||||||
@@ -65,16 +65,14 @@ class ImagePacket {
|
|||||||
.sublist(1, 5)
|
.sublist(1, 5)
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join();
|
.join();
|
||||||
final fmtId = payload[5];
|
final index = payload[5];
|
||||||
final index = payload[6];
|
final data = payload.sublist(_headerLen);
|
||||||
final total = payload[7];
|
|
||||||
if (total < 1) return null;
|
|
||||||
return ImagePacket(
|
return ImagePacket(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
format: ImageFormat.fromId(fmtId),
|
format: ImageFormat.avif,
|
||||||
index: index,
|
index: index,
|
||||||
total: total,
|
total: 0,
|
||||||
data: payload.sublist(_headerLen),
|
data: data,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -92,16 +90,16 @@ class ImagePacket {
|
|||||||
final out = Uint8List(_headerLen + data.length);
|
final out = Uint8List(_headerLen + data.length);
|
||||||
out[0] = _magic;
|
out[0] = _magic;
|
||||||
out.setRange(1, 5, sessionBytes);
|
out.setRange(1, 5, sessionBytes);
|
||||||
out[5] = format.id;
|
out[5] = index;
|
||||||
out[6] = index;
|
|
||||||
out[7] = total;
|
|
||||||
out.setRange(_headerLen, out.length, data);
|
out.setRange(_headerLen, out.length, data);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() {
|
||||||
'ImagePacket($sessionId ${format.label} [$index/${total - 1}] ${data.length}B)';
|
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.
|
/// 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).
|
/// Envelope announcing image availability (control plane).
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
|
/// IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8
|
/// IE4:deadbeef:0:7:3k:3k:t6
|
||||||
class ImageEnvelope {
|
class ImageEnvelope {
|
||||||
static const String _prefix = 'IE2:';
|
static const String _prefixV4 = 'IE4:';
|
||||||
|
|
||||||
final String sessionId; // 8 hex chars
|
final String sessionId; // 8 hex chars
|
||||||
final ImageFormat format;
|
final ImageFormat format;
|
||||||
@@ -258,8 +256,6 @@ class ImageEnvelope {
|
|||||||
final int width;
|
final int width;
|
||||||
final int height;
|
final int height;
|
||||||
final int sizeBytes; // total compressed image size
|
final int sizeBytes; // total compressed image size
|
||||||
final String senderKey6; // 12 hex chars (6 bytes)
|
|
||||||
final int timestampSec;
|
|
||||||
final int version;
|
final int version;
|
||||||
|
|
||||||
const ImageEnvelope({
|
const ImageEnvelope({
|
||||||
@@ -269,18 +265,16 @@ class ImageEnvelope {
|
|||||||
required this.width,
|
required this.width,
|
||||||
required this.height,
|
required this.height,
|
||||||
required this.sizeBytes,
|
required this.sizeBytes,
|
||||||
required this.senderKey6,
|
this.version = 4,
|
||||||
required this.timestampSec,
|
|
||||||
this.version = 2,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isEnvelope(String text) => text.startsWith(_prefix);
|
static bool isEnvelope(String text) => text.startsWith(_prefixV4);
|
||||||
|
|
||||||
static ImageEnvelope? tryParse(String text) {
|
static ImageEnvelope? tryParse(String text) {
|
||||||
if (!isEnvelope(text)) return null;
|
if (!isEnvelope(text)) return null;
|
||||||
final body = text.substring(_prefix.length);
|
final body = text.substring(_prefixV4.length);
|
||||||
final parts = body.split(':');
|
final parts = body.split(':');
|
||||||
if (parts.length != 8) return null;
|
if (parts.length != 6) return null;
|
||||||
try {
|
try {
|
||||||
final sid = _decodeSessionId(parts[0]);
|
final sid = _decodeSessionId(parts[0]);
|
||||||
final fmtId = _parseInt(parts[1], base36: true);
|
final fmtId = _parseInt(parts[1], base36: true);
|
||||||
@@ -288,16 +282,12 @@ class ImageEnvelope {
|
|||||||
final w = _parseInt(parts[3], base36: true);
|
final w = _parseInt(parts[3], base36: true);
|
||||||
final h = _parseInt(parts[4], base36: true);
|
final h = _parseInt(parts[4], base36: true);
|
||||||
final bytes = _parseInt(parts[5], 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 (sid == null) return null;
|
||||||
if (fmtId == null) return null;
|
if (fmtId == null) return null;
|
||||||
if (total == null || total < 1 || total > 255) return null;
|
if (total == null || total < 1 || total > 255) return null;
|
||||||
if (w == null || h == null || w < 1 || h < 1) return null;
|
if (w == null || h == null || w < 1 || h < 1) return null;
|
||||||
if (bytes == null || bytes < 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(
|
return ImageEnvelope(
|
||||||
sessionId: sid,
|
sessionId: sid,
|
||||||
@@ -306,9 +296,7 @@ class ImageEnvelope {
|
|||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
sizeBytes: bytes,
|
sizeBytes: bytes,
|
||||||
senderKey6: senderKey6.toLowerCase(),
|
version: 4,
|
||||||
timestampSec: ts,
|
|
||||||
version: 2,
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -316,27 +304,25 @@ class ImageEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String encode() =>
|
String encode() =>
|
||||||
'$_prefix${_encodeSessionId(sessionId)}:'
|
'$_prefixV4${_encodeSessionId(sessionId)}:'
|
||||||
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
|
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
|
||||||
'${_toBase36(height)}:${_toBase36(sizeBytes)}:'
|
'${_toBase36(height)}:${_toBase36(sizeBytes)}';
|
||||||
'${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direct request to fetch image fragments (control plane).
|
/// Direct request to fetch image fragments (control plane).
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// IR2:{sid}:{want}:{requesterKey6}:{ts}
|
/// IR4:{sid}:{want}:{requesterKey6}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// IR2:deadbeef:a:aabbccddeeff:s44wea
|
/// IR4:deadbeef:a:aabbccddeeff
|
||||||
class ImageFetchRequest {
|
class ImageFetchRequest {
|
||||||
static const String _prefix = 'IR2:';
|
static const String _prefixV4 = 'IR4:';
|
||||||
static const int _binaryMagic = 0x69; // 'i'
|
static const int _binaryMagic = 0x69; // 'i'
|
||||||
|
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final String want; // 'all' or 'missing'
|
final String want; // 'all' or 'missing'
|
||||||
final List<int> missingIndices;
|
final List<int> missingIndices;
|
||||||
final String requesterKey6; // 12 hex chars
|
final String requesterKey6; // 12 hex chars
|
||||||
final int timestampSec;
|
|
||||||
final int version;
|
final int version;
|
||||||
|
|
||||||
const ImageFetchRequest({
|
const ImageFetchRequest({
|
||||||
@@ -344,24 +330,22 @@ class ImageFetchRequest {
|
|||||||
this.want = 'all',
|
this.want = 'all',
|
||||||
this.missingIndices = const [],
|
this.missingIndices = const [],
|
||||||
required this.requesterKey6,
|
required this.requesterKey6,
|
||||||
required this.timestampSec,
|
this.version = 4,
|
||||||
this.version = 2,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isRequest(String text) => text.startsWith(_prefix);
|
static bool isRequest(String text) => text.startsWith(_prefixV4);
|
||||||
static bool isRequestBinary(Uint8List payload) =>
|
static bool isRequestBinary(Uint8List payload) =>
|
||||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||||
|
|
||||||
static ImageFetchRequest? tryParse(String text) {
|
static ImageFetchRequest? tryParse(String text) {
|
||||||
if (!isRequest(text)) return null;
|
if (!isRequest(text)) return null;
|
||||||
final body = text.substring(_prefix.length);
|
final body = text.substring(_prefixV4.length);
|
||||||
final parts = body.split(':');
|
final parts = body.split(':');
|
||||||
if (parts.length != 4) return null;
|
if (parts.length != 3) return null;
|
||||||
try {
|
try {
|
||||||
final sid = _decodeSessionId(parts[0]);
|
final sid = _decodeSessionId(parts[0]);
|
||||||
final wantToken = parts[1];
|
final wantToken = parts[1];
|
||||||
final requesterKey6 = parts[2];
|
final requesterKey6 = parts[2];
|
||||||
final ts = _parseInt(parts[3], base36: true);
|
|
||||||
final normalizedWant = wantToken == 'a'
|
final normalizedWant = wantToken == 'a'
|
||||||
? 'all'
|
? 'all'
|
||||||
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
|
: ((wantToken.startsWith('m')) ? 'missing' : wantToken);
|
||||||
@@ -377,15 +361,13 @@ class ImageFetchRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
|
||||||
if (ts == null || ts <= 0) return null;
|
|
||||||
|
|
||||||
return ImageFetchRequest(
|
return ImageFetchRequest(
|
||||||
sessionId: sid,
|
sessionId: sid,
|
||||||
want: normalizedWant,
|
want: normalizedWant,
|
||||||
missingIndices: missingIndices,
|
missingIndices: missingIndices,
|
||||||
requesterKey6: requesterKey6.toLowerCase(),
|
requesterKey6: requesterKey6.toLowerCase(),
|
||||||
timestampSec: ts,
|
version: 4,
|
||||||
version: 2,
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -394,7 +376,7 @@ class ImageFetchRequest {
|
|||||||
|
|
||||||
static ImageFetchRequest? tryParseBinary(Uint8List payload) {
|
static ImageFetchRequest? tryParseBinary(Uint8List payload) {
|
||||||
if (!isRequestBinary(payload)) return null;
|
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 {
|
try {
|
||||||
final sid = payload
|
final sid = payload
|
||||||
.sublist(1, 5)
|
.sublist(1, 5)
|
||||||
@@ -407,25 +389,19 @@ class ImageFetchRequest {
|
|||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join()
|
.join()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
final ts =
|
final missingCount = payload[12];
|
||||||
(payload[12] << 24) |
|
if (payload.length != 13 + missingCount) return null;
|
||||||
(payload[13] << 16) |
|
|
||||||
(payload[14] << 8) |
|
|
||||||
payload[15];
|
|
||||||
final missingCount = payload[16];
|
|
||||||
if (payload.length != 17 + missingCount) return null;
|
|
||||||
final wantMissing = (flags & 0x01) == 0x01;
|
final wantMissing = (flags & 0x01) == 0x01;
|
||||||
final missing = <int>[];
|
final missing = <int>[];
|
||||||
for (var i = 0; i < missingCount; i++) {
|
for (var i = 0; i < missingCount; i++) {
|
||||||
missing.add(payload[17 + i]);
|
missing.add(payload[13 + i]);
|
||||||
}
|
}
|
||||||
return ImageFetchRequest(
|
return ImageFetchRequest(
|
||||||
sessionId: sid,
|
sessionId: sid,
|
||||||
want: wantMissing ? 'missing' : 'all',
|
want: wantMissing ? 'missing' : 'all',
|
||||||
missingIndices: missing,
|
missingIndices: missing,
|
||||||
requesterKey6: requesterKey6,
|
requesterKey6: requesterKey6,
|
||||||
timestampSec: ts,
|
version: 4,
|
||||||
version: 2,
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -436,7 +412,7 @@ class ImageFetchRequest {
|
|||||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||||
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||||
: (want == 'all' ? 'a' : want);
|
: (want == 'all' ? 'a' : want);
|
||||||
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
return '$_prefixV4${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Uint8List encodeBinary() {
|
Uint8List encodeBinary() {
|
||||||
@@ -455,7 +431,7 @@ class ImageFetchRequest {
|
|||||||
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
||||||
: <int>[];
|
: <int>[];
|
||||||
|
|
||||||
final out = Uint8List(17 + missing.length);
|
final out = Uint8List(13 + missing.length);
|
||||||
out[0] = _binaryMagic;
|
out[0] = _binaryMagic;
|
||||||
for (var i = 0; i < 4; i++) {
|
for (var i = 0; i < 4; i++) {
|
||||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||||
@@ -467,13 +443,9 @@ class ImageFetchRequest {
|
|||||||
radix: 16,
|
radix: 16,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
out[12] = (timestampSec >> 24) & 0xFF;
|
out[12] = missing.length;
|
||||||
out[13] = (timestampSec >> 16) & 0xFF;
|
|
||||||
out[14] = (timestampSec >> 8) & 0xFF;
|
|
||||||
out[15] = timestampSec & 0xFF;
|
|
||||||
out[16] = missing.length;
|
|
||||||
for (var i = 0; i < missing.length; i++) {
|
for (var i = 0; i < missing.length; i++) {
|
||||||
out[17 + i] = missing[i];
|
out[13 + i] = missing[i];
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,38 +4,51 @@ import '../models/contact.dart';
|
|||||||
|
|
||||||
class DecodedLogRxRoute {
|
class DecodedLogRxRoute {
|
||||||
final int payloadType;
|
final int payloadType;
|
||||||
final List<int> pathHashes;
|
final int pathDescriptor;
|
||||||
|
final List<int> pathBytes;
|
||||||
|
final int hashSize;
|
||||||
|
|
||||||
const DecodedLogRxRoute({
|
const DecodedLogRxRoute({
|
||||||
required this.payloadType,
|
required this.payloadType,
|
||||||
required this.pathHashes,
|
required this.pathDescriptor,
|
||||||
|
required this.pathBytes,
|
||||||
|
required this.hashSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first;
|
List<String> get hopHashes =>
|
||||||
|
LogRxRouteDecoder.splitHopHashes(pathBytes, hashSize: hashSize);
|
||||||
|
|
||||||
|
int get hopCount => hopHashes.length;
|
||||||
|
|
||||||
|
String? get originalSenderHashHex =>
|
||||||
|
hopHashes.isEmpty ? null : hopHashes.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResolvedNodeHash {
|
class ResolvedNodeHash {
|
||||||
final int hash;
|
final String hashHex;
|
||||||
final String label;
|
final String label;
|
||||||
final bool isOwnNode;
|
final bool isOwnNode;
|
||||||
final bool isUniqueMatch;
|
final bool isUniqueMatch;
|
||||||
final int matchCount;
|
final int matchCount;
|
||||||
|
|
||||||
const ResolvedNodeHash({
|
const ResolvedNodeHash({
|
||||||
required this.hash,
|
required this.hashHex,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.isOwnNode,
|
required this.isOwnNode,
|
||||||
required this.isUniqueMatch,
|
required this.isUniqueMatch,
|
||||||
required this.matchCount,
|
required this.matchCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}';
|
String get hexLabel => '0x${hashHex.toUpperCase()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
class LogRxRouteDecoder {
|
class LogRxRouteDecoder {
|
||||||
const LogRxRouteDecoder._();
|
const LogRxRouteDecoder._();
|
||||||
|
|
||||||
static DecodedLogRxRoute? decode(Uint8List rawData) {
|
static DecodedLogRxRoute? decode(
|
||||||
|
Uint8List rawData, {
|
||||||
|
int? preferredHashSize,
|
||||||
|
}) {
|
||||||
if (rawData.length < 5 || rawData[0] != 0x88) return null;
|
if (rawData.length < 5 || rawData[0] != 0x88) return null;
|
||||||
|
|
||||||
final rawPacketData = rawData.sublist(3);
|
final rawPacketData = rawData.sublist(3);
|
||||||
@@ -52,30 +65,109 @@ class LogRxRouteDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (rawPacketData.length <= index) return null;
|
if (rawPacketData.length <= index) return null;
|
||||||
final pathLen = rawPacketData[index++];
|
final pathDescriptor = rawPacketData[index++];
|
||||||
if (rawPacketData.length < index + pathLen) return null;
|
final pathMode = (pathDescriptor & 0xFF) >> 6;
|
||||||
|
final 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(
|
return DecodedLogRxRoute(
|
||||||
payloadType: payloadType,
|
payloadType: payloadType,
|
||||||
pathHashes: rawPacketData.sublist(index, index + pathLen),
|
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(
|
static ResolvedNodeHash resolveHash(
|
||||||
int hash, {
|
String hashHex, {
|
||||||
required Iterable<Contact> contacts,
|
required Iterable<Contact> contacts,
|
||||||
Uint8List? ownPublicKey,
|
Uint8List? ownPublicKey,
|
||||||
String? ownName,
|
String? ownName,
|
||||||
}) {
|
}) {
|
||||||
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
|
final normalizedHashHex = hashHex.toLowerCase();
|
||||||
? ownPublicKey.first
|
final ownKeyHex = _bytesToHex(ownPublicKey);
|
||||||
: null;
|
if (ownKeyHex != null && ownKeyHex.startsWith(normalizedHashHex)) {
|
||||||
if (ownHash == hash) {
|
|
||||||
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
|
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
|
||||||
? '$ownName (you)'
|
? '$ownName (you)'
|
||||||
: 'You';
|
: 'You';
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: ownLabel,
|
label: ownLabel,
|
||||||
isOwnNode: true,
|
isOwnNode: true,
|
||||||
isUniqueMatch: true,
|
isUniqueMatch: true,
|
||||||
@@ -84,12 +176,12 @@ class LogRxRouteDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final matches = contacts.where((contact) {
|
final matches = contacts.where((contact) {
|
||||||
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
|
return contact.publicKeyHex.toLowerCase().startsWith(normalizedHashHex);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
if (matches.isEmpty) {
|
if (matches.isEmpty) {
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: 'Unknown',
|
label: 'Unknown',
|
||||||
isOwnNode: false,
|
isOwnNode: false,
|
||||||
isUniqueMatch: false,
|
isUniqueMatch: false,
|
||||||
@@ -99,7 +191,7 @@ class LogRxRouteDecoder {
|
|||||||
|
|
||||||
if (matches.length == 1) {
|
if (matches.length == 1) {
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: matches.first.displayName,
|
label: matches.first.displayName,
|
||||||
isOwnNode: false,
|
isOwnNode: false,
|
||||||
isUniqueMatch: true,
|
isUniqueMatch: true,
|
||||||
@@ -119,11 +211,19 @@ class LogRxRouteDecoder {
|
|||||||
? '$candidateNames +$extraCount'
|
? '$candidateNames +$extraCount'
|
||||||
: candidateNames;
|
: candidateNames;
|
||||||
return ResolvedNodeHash(
|
return ResolvedNodeHash(
|
||||||
hash: hash,
|
hashHex: normalizedHashHex,
|
||||||
label: label,
|
label: label,
|
||||||
isOwnNode: false,
|
isOwnNode: false,
|
||||||
isUniqueMatch: false,
|
isUniqueMatch: false,
|
||||||
matchCount: matches.length,
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
168
lib/utils/media_swarm_protocol.dart
Normal file
168
lib/utils/media_swarm_protocol.dart
Normal 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();
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../models/message.dart';
|
import '../models/message.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../providers/messages_provider.dart';
|
||||||
|
|
||||||
/// Extension for Message to provide localized delivery status
|
/// Extension for Message to provide localized delivery status
|
||||||
extension MessageLocalization on Message {
|
extension MessageLocalization on Message {
|
||||||
/// Get localized delivery status text
|
/// Get localized delivery status text
|
||||||
String getLocalizedDeliveryStatus(BuildContext context) {
|
String getLocalizedDeliveryStatus(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final routeMetadata = context
|
||||||
|
.read<MessagesProvider>()
|
||||||
|
.getMessageRouteMetadata(id);
|
||||||
|
|
||||||
// For channel messages, show echo count instead of delivery status
|
// For channel messages, show echo count instead of delivery status
|
||||||
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
if (isChannelMessage && deliveryStatus == MessageDeliveryStatus.sent) {
|
||||||
@@ -26,29 +31,51 @@ extension MessageLocalization on Message {
|
|||||||
case MessageDeliveryStatus.sending:
|
case MessageDeliveryStatus.sending:
|
||||||
if (isContactMessage) {
|
if (isContactMessage) {
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.pending} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
return l10n.pending;
|
return routeMetadata == null
|
||||||
|
? l10n.pending
|
||||||
|
: '${l10n.pending} • ${routeMetadata.modeLabel}';
|
||||||
}
|
}
|
||||||
return l10n.sending;
|
return l10n.sending;
|
||||||
case MessageDeliveryStatus.sent:
|
case MessageDeliveryStatus.sent:
|
||||||
return l10n.sent;
|
return routeMetadata == null
|
||||||
|
? l10n.sent
|
||||||
|
: '${l10n.sent} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.delivered:
|
case MessageDeliveryStatus.delivered:
|
||||||
if (retryAttempt > 0 && roundTripTimeMs != null) {
|
if (retryAttempt > 0 && roundTripTimeMs != null) {
|
||||||
return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.delivered} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
if (roundTripTimeMs != null) {
|
if (roundTripTimeMs != null) {
|
||||||
return l10n.deliveredWithTime(roundTripTimeMs!);
|
return routeMetadata == null
|
||||||
|
? l10n.deliveredWithTime(roundTripTimeMs!)
|
||||||
|
: '${l10n.deliveredWithTime(roundTripTimeMs!)} • ${routeMetadata.modeLabel}';
|
||||||
}
|
}
|
||||||
return l10n.delivered;
|
return routeMetadata == null
|
||||||
|
? l10n.delivered
|
||||||
|
: '${l10n.delivered} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.failed:
|
case MessageDeliveryStatus.failed:
|
||||||
if (retryAttempt > 0) {
|
if (retryAttempt > 0) {
|
||||||
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/3';
|
final routeSuffix = routeMetadata != null
|
||||||
|
? ' • ${routeMetadata.modeLabel}'
|
||||||
|
: '';
|
||||||
|
return '${l10n.failed} • ${l10n.retryAttempt} $retryAttempt/4$routeSuffix';
|
||||||
}
|
}
|
||||||
return l10n.failed;
|
return routeMetadata == null
|
||||||
|
? l10n.failed
|
||||||
|
: '${l10n.failed} • ${routeMetadata.modeLabel}';
|
||||||
case MessageDeliveryStatus.received:
|
case MessageDeliveryStatus.received:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ import 'dart:typed_data';
|
|||||||
import '../models/contact.dart';
|
import '../models/contact.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
|
|
||||||
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar, unreachable }
|
enum TransmissionTargetFailure {
|
||||||
|
unknownContact,
|
||||||
|
unknownRoute,
|
||||||
|
tooFar,
|
||||||
|
unreachable,
|
||||||
|
}
|
||||||
|
|
||||||
class TransmissionTargetResolution {
|
class TransmissionTargetResolution {
|
||||||
final Contact? target;
|
final Contact? target;
|
||||||
@@ -16,7 +21,7 @@ class TransmissionTargetResolution {
|
|||||||
required this.maxHops,
|
required this.maxHops,
|
||||||
});
|
});
|
||||||
|
|
||||||
int get hops => target?.outPathLen ?? -1;
|
int get hops => target?.routeHopCount ?? -1;
|
||||||
bool get isValid => target != null && failure == null;
|
bool get isValid => target != null && failure == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,11 +37,17 @@ class TransmissionTargetResolver {
|
|||||||
String? senderName,
|
String? senderName,
|
||||||
}) {
|
}) {
|
||||||
if (isSentByMe) {
|
if (isSentByMe) {
|
||||||
final recipient = _findByRecipientKey(contactsProvider, recipientPublicKey);
|
final recipient = _findByRecipientKey(
|
||||||
|
contactsProvider,
|
||||||
|
recipientPublicKey,
|
||||||
|
);
|
||||||
if (recipient != null) return recipient;
|
if (recipient != null) return recipient;
|
||||||
}
|
}
|
||||||
|
|
||||||
final byEnvelope = _findByEnvelopeKey6(contactsProvider, senderKey6FromEnvelope);
|
final byEnvelope = _findByEnvelopeKey6(
|
||||||
|
contactsProvider,
|
||||||
|
senderKey6FromEnvelope,
|
||||||
|
);
|
||||||
if (byEnvelope != null) return byEnvelope;
|
if (byEnvelope != null) return byEnvelope;
|
||||||
|
|
||||||
final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix);
|
final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix);
|
||||||
@@ -64,7 +75,9 @@ class TransmissionTargetResolver {
|
|||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (target == null || target.outPathLen < 0 || target.outPathLen > maxFetchHops) {
|
if (target == null ||
|
||||||
|
!target.routeHasPath ||
|
||||||
|
target.routeHopCount > maxFetchHops) {
|
||||||
await refreshContacts();
|
await refreshContacts();
|
||||||
target = resolveLocalTarget(
|
target = resolveLocalTarget(
|
||||||
contactsProvider: contactsProvider,
|
contactsProvider: contactsProvider,
|
||||||
@@ -83,14 +96,14 @@ class TransmissionTargetResolver {
|
|||||||
maxHops: maxFetchHops,
|
maxHops: maxFetchHops,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (target.outPathLen < 0) {
|
if (!target.routeHasPath) {
|
||||||
return TransmissionTargetResolution(
|
return TransmissionTargetResolution(
|
||||||
target: target,
|
target: target,
|
||||||
failure: TransmissionTargetFailure.unknownRoute,
|
failure: TransmissionTargetFailure.unknownRoute,
|
||||||
maxHops: maxFetchHops,
|
maxHops: maxFetchHops,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (target.outPathLen > maxFetchHops) {
|
if (target.routeHopCount > maxFetchHops) {
|
||||||
return TransmissionTargetResolution(
|
return TransmissionTargetResolution(
|
||||||
target: target,
|
target: target,
|
||||||
failure: TransmissionTargetFailure.tooFar,
|
failure: TransmissionTargetFailure.tooFar,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import 'dart:convert';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
|
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
|
||||||
const int _voicePacketHeaderBytes = 8; // voice packet binary header in payload
|
const int _voicePacketHeaderBytes = 6; // voice packet binary header in payload
|
||||||
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
|
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
|
||||||
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
|
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
|
||||||
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
|
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
|
||||||
@@ -38,7 +38,7 @@ enum VoicePacketMode {
|
|||||||
/// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2}
|
/// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2}
|
||||||
///
|
///
|
||||||
/// Binary format (direct contacts, received via pushRawData):
|
/// Binary format (direct contacts, received via pushRawData):
|
||||||
/// [0x56 'V'][sessionId:4B][modeId:1B][index:1B][total:1B][codec2Data...]
|
/// [0x56 'V'][sessionId:4B][index:1B][codec2Data...]
|
||||||
class VoicePacket {
|
class VoicePacket {
|
||||||
final String sessionId; // 8 hex chars (4 bytes)
|
final String sessionId; // 8 hex chars (4 bytes)
|
||||||
final VoicePacketMode mode;
|
final VoicePacketMode mode;
|
||||||
@@ -104,8 +104,7 @@ class VoicePacket {
|
|||||||
// ── Binary format ────────────────────────────────────────────────────────
|
// ── Binary format ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
static const int _binaryMagic = 0x56; // 'V'
|
static const int _binaryMagic = 0x56; // 'V'
|
||||||
static const int _binaryHeaderLen =
|
static const int _binaryHeaderLen = 6; // magic(1)+session(4)+idx(1)
|
||||||
8; // magic(1)+session(4)+mode(1)+idx(1)+total(1)
|
|
||||||
|
|
||||||
static bool isVoiceBinary(Uint8List payload) =>
|
static bool isVoiceBinary(Uint8List payload) =>
|
||||||
payload.isNotEmpty && payload[0] == _binaryMagic;
|
payload.isNotEmpty && payload[0] == _binaryMagic;
|
||||||
@@ -119,16 +118,13 @@ class VoicePacket {
|
|||||||
final sessionId = sessionBytes
|
final sessionId = sessionBytes
|
||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join();
|
.join();
|
||||||
final modeId = payload[5];
|
final index = payload[5];
|
||||||
final index = payload[6];
|
|
||||||
final total = payload[7];
|
|
||||||
if (total < 1) return null;
|
|
||||||
final codec2Data = payload.sublist(_binaryHeaderLen);
|
final codec2Data = payload.sublist(_binaryHeaderLen);
|
||||||
return VoicePacket(
|
return VoicePacket(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
mode: VoicePacketMode.fromId(modeId),
|
mode: VoicePacketMode.mode1300,
|
||||||
index: index,
|
index: index,
|
||||||
total: total,
|
total: 0,
|
||||||
codec2Data: codec2Data,
|
codec2Data: codec2Data,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -148,9 +144,7 @@ class VoicePacket {
|
|||||||
final out = Uint8List(_binaryHeaderLen + codec2Data.length);
|
final out = Uint8List(_binaryHeaderLen + codec2Data.length);
|
||||||
out[0] = _binaryMagic;
|
out[0] = _binaryMagic;
|
||||||
out.setRange(1, 5, sessionBytes);
|
out.setRange(1, 5, sessionBytes);
|
||||||
out[5] = mode.id;
|
out[5] = index;
|
||||||
out[6] = index;
|
|
||||||
out[7] = total;
|
|
||||||
out.setRange(_binaryHeaderLen, out.length, codec2Data);
|
out.setRange(_binaryHeaderLen, out.length, codec2Data);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -174,25 +168,25 @@ class VoicePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() =>
|
String toString() {
|
||||||
'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)';
|
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]';
|
||||||
|
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lightweight public/direct message envelope advertising voice availability.
|
/// Lightweight public/direct message envelope advertising voice availability.
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
|
/// VE3:{sid}:{mode}:{total}:{durS}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi
|
/// VE3:00112233:1:4:4
|
||||||
class VoiceEnvelope {
|
class VoiceEnvelope {
|
||||||
static const String _prefix = 'VE2:';
|
static const String _prefix = 'VE3:';
|
||||||
|
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final VoicePacketMode mode;
|
final VoicePacketMode mode;
|
||||||
final int total;
|
final int total;
|
||||||
final int durationMs;
|
final int durationMs;
|
||||||
final String senderKey6;
|
|
||||||
final int timestampSec;
|
|
||||||
final int version;
|
final int version;
|
||||||
|
|
||||||
const VoiceEnvelope({
|
const VoiceEnvelope({
|
||||||
@@ -200,9 +194,7 @@ class VoiceEnvelope {
|
|||||||
required this.mode,
|
required this.mode,
|
||||||
required this.total,
|
required this.total,
|
||||||
required this.durationMs,
|
required this.durationMs,
|
||||||
required this.senderKey6,
|
this.version = 3,
|
||||||
required this.timestampSec,
|
|
||||||
this.version = 2,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
|
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
|
||||||
@@ -215,14 +207,12 @@ class VoiceEnvelope {
|
|||||||
|
|
||||||
static VoiceEnvelope? _tryParse(String body) {
|
static VoiceEnvelope? _tryParse(String body) {
|
||||||
final parts = body.split(':');
|
final parts = body.split(':');
|
||||||
if (parts.length != 6) return null;
|
if (parts.length != 4) return null;
|
||||||
try {
|
try {
|
||||||
final sid = _decodeSessionId(parts[0]);
|
final sid = _decodeSessionId(parts[0]);
|
||||||
final mode = _parseInt(parts[1], base36: true);
|
final mode = _parseInt(parts[1], base36: true);
|
||||||
final total = _parseInt(parts[2], base36: true);
|
final total = _parseInt(parts[2], base36: true);
|
||||||
final durS = _parseInt(parts[3], base36: true);
|
final durS = _parseInt(parts[3], base36: true);
|
||||||
final senderKey6 = parts[4];
|
|
||||||
final ts = _parseInt(parts[5], base36: true);
|
|
||||||
|
|
||||||
if (sid == null) {
|
if (sid == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -232,19 +222,13 @@ class VoiceEnvelope {
|
|||||||
}
|
}
|
||||||
if (total == null || total < 1 || total > 255) return null;
|
if (total == null || total < 1 || total > 255) return null;
|
||||||
if (durS == null || durS < 0 || durS > 10 * 60) return null;
|
if (durS == null || durS < 0 || durS > 10 * 60) return null;
|
||||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (ts == null || ts <= 0) return null;
|
|
||||||
|
|
||||||
return VoiceEnvelope(
|
return VoiceEnvelope(
|
||||||
sessionId: sid,
|
sessionId: sid,
|
||||||
mode: VoicePacketMode.fromId(mode),
|
mode: VoicePacketMode.fromId(mode),
|
||||||
total: total,
|
total: total,
|
||||||
durationMs: durS * 1000,
|
durationMs: durS * 1000,
|
||||||
senderKey6: senderKey6.toLowerCase(),
|
version: 3,
|
||||||
timestampSec: ts,
|
|
||||||
version: 2,
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -253,7 +237,7 @@ class VoiceEnvelope {
|
|||||||
|
|
||||||
String encodeText() {
|
String encodeText() {
|
||||||
final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
|
final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
|
||||||
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}:${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,18 +398,17 @@ int _resolveBandwidthHz(int? rawBw) {
|
|||||||
/// Direct control-plane request to fetch voice packets for a session.
|
/// Direct control-plane request to fetch voice packets for a session.
|
||||||
///
|
///
|
||||||
/// Text format:
|
/// Text format:
|
||||||
/// VR2:{sid}:{want}:{requesterKey6}:{ts}
|
/// VR3:{sid}:{want}:{requesterKey6}
|
||||||
/// Example:
|
/// Example:
|
||||||
/// VR2:00112233:a:aabbccddeeff:kf12oi
|
/// VR3:00112233:a:aabbccddeeff
|
||||||
class VoiceFetchRequest {
|
class VoiceFetchRequest {
|
||||||
static const String _prefix = 'VR2:';
|
static const String _prefix = 'VR3:';
|
||||||
static const int _binaryMagic = 0x72; // 'r'
|
static const int _binaryMagic = 0x72; // 'r'
|
||||||
|
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
final String want;
|
final String want;
|
||||||
final List<int> missingIndices;
|
final List<int> missingIndices;
|
||||||
final String requesterKey6;
|
final String requesterKey6;
|
||||||
final int timestampSec;
|
|
||||||
final int version;
|
final int version;
|
||||||
|
|
||||||
const VoiceFetchRequest({
|
const VoiceFetchRequest({
|
||||||
@@ -433,8 +416,7 @@ class VoiceFetchRequest {
|
|||||||
this.want = 'all',
|
this.want = 'all',
|
||||||
this.missingIndices = const [],
|
this.missingIndices = const [],
|
||||||
required this.requesterKey6,
|
required this.requesterKey6,
|
||||||
required this.timestampSec,
|
this.version = 3,
|
||||||
this.version = 2,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
static bool isVoiceFetchRequestText(String text) =>
|
static bool isVoiceFetchRequestText(String text) =>
|
||||||
@@ -450,7 +432,7 @@ class VoiceFetchRequest {
|
|||||||
|
|
||||||
static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
|
static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
|
||||||
if (!isVoiceFetchRequestBinary(payload)) return null;
|
if (!isVoiceFetchRequestBinary(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 {
|
try {
|
||||||
final sidBytes = payload.sublist(1, 5);
|
final sidBytes = payload.sublist(1, 5);
|
||||||
final sid = sidBytes
|
final sid = sidBytes
|
||||||
@@ -463,25 +445,19 @@ class VoiceFetchRequest {
|
|||||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||||
.join()
|
.join()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
final ts =
|
final missingCount = payload[12];
|
||||||
(payload[12] << 24) |
|
if (payload.length != 13 + missingCount) return null;
|
||||||
(payload[13] << 16) |
|
|
||||||
(payload[14] << 8) |
|
|
||||||
payload[15];
|
|
||||||
final missingCount = payload[16];
|
|
||||||
if (payload.length != 17 + missingCount) return null;
|
|
||||||
final wantMissing = (flags & 0x01) == 0x01;
|
final wantMissing = (flags & 0x01) == 0x01;
|
||||||
final missing = <int>[];
|
final missing = <int>[];
|
||||||
for (var i = 0; i < missingCount; i++) {
|
for (var i = 0; i < missingCount; i++) {
|
||||||
missing.add(payload[17 + i]);
|
missing.add(payload[13 + i]);
|
||||||
}
|
}
|
||||||
return VoiceFetchRequest(
|
return VoiceFetchRequest(
|
||||||
sessionId: sid,
|
sessionId: sid,
|
||||||
want: wantMissing ? 'missing' : 'all',
|
want: wantMissing ? 'missing' : 'all',
|
||||||
missingIndices: missing,
|
missingIndices: missing,
|
||||||
requesterKey6: requesterKey6,
|
requesterKey6: requesterKey6,
|
||||||
timestampSec: ts,
|
version: 3,
|
||||||
version: 2,
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -490,12 +466,11 @@ class VoiceFetchRequest {
|
|||||||
|
|
||||||
static VoiceFetchRequest? _tryParse(String body) {
|
static VoiceFetchRequest? _tryParse(String body) {
|
||||||
final parts = body.split(':');
|
final parts = body.split(':');
|
||||||
if (parts.length != 4) return null;
|
if (parts.length != 3) return null;
|
||||||
try {
|
try {
|
||||||
final sid = _decodeSessionId(parts[0]);
|
final sid = _decodeSessionId(parts[0]);
|
||||||
final wantToken = parts[1];
|
final wantToken = parts[1];
|
||||||
final requesterKey6 = parts[2];
|
final requesterKey6 = parts[2];
|
||||||
final ts = _parseInt(parts[3], base36: true);
|
|
||||||
final normalizedWant = wantToken == 'a'
|
final normalizedWant = wantToken == 'a'
|
||||||
? 'all'
|
? 'all'
|
||||||
: ((wantToken.startsWith('m'))
|
: ((wantToken.startsWith('m'))
|
||||||
@@ -517,15 +492,13 @@ class VoiceFetchRequest {
|
|||||||
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (ts == null || ts <= 0) return null;
|
|
||||||
|
|
||||||
return VoiceFetchRequest(
|
return VoiceFetchRequest(
|
||||||
sessionId: sid,
|
sessionId: sid,
|
||||||
want: normalizedWant,
|
want: normalizedWant,
|
||||||
missingIndices: missingIndices,
|
missingIndices: missingIndices,
|
||||||
requesterKey6: requesterKey6.toLowerCase(),
|
requesterKey6: requesterKey6.toLowerCase(),
|
||||||
timestampSec: ts,
|
version: 3,
|
||||||
version: 2,
|
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
@@ -536,7 +509,7 @@ class VoiceFetchRequest {
|
|||||||
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
final wantToken = want == 'missing' && missingIndices.isNotEmpty
|
||||||
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
|
||||||
: (want == 'all' ? 'a' : want);
|
: (want == 'all' ? 'a' : want);
|
||||||
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
|
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Uint8List encodeBinary() {
|
Uint8List encodeBinary() {
|
||||||
@@ -555,7 +528,7 @@ class VoiceFetchRequest {
|
|||||||
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
|
||||||
: <int>[];
|
: <int>[];
|
||||||
|
|
||||||
final out = Uint8List(17 + missing.length);
|
final out = Uint8List(13 + missing.length);
|
||||||
out[0] = _binaryMagic;
|
out[0] = _binaryMagic;
|
||||||
for (var i = 0; i < 4; i++) {
|
for (var i = 0; i < 4; i++) {
|
||||||
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
|
||||||
@@ -567,13 +540,9 @@ class VoiceFetchRequest {
|
|||||||
radix: 16,
|
radix: 16,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
out[12] = (timestampSec >> 24) & 0xFF;
|
out[12] = missing.length;
|
||||||
out[13] = (timestampSec >> 16) & 0xFF;
|
|
||||||
out[14] = (timestampSec >> 8) & 0xFF;
|
|
||||||
out[15] = timestampSec & 0xFF;
|
|
||||||
out[16] = missing.length;
|
|
||||||
for (var i = 0; i < missing.length; i++) {
|
for (var i = 0; i < missing.length; i++) {
|
||||||
out[17 + i] = missing[i];
|
out[13 + i] = missing[i];
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class ContactAvatar extends StatelessWidget {
|
|||||||
final foregroundColor = _getForegroundColor(backgroundColor);
|
final foregroundColor = _getForegroundColor(backgroundColor);
|
||||||
final emoji = contact.roleEmoji;
|
final emoji = contact.roleEmoji;
|
||||||
|
|
||||||
if (emoji != null && emoji.isNotEmpty) {
|
if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) {
|
||||||
return _buildAvatarFrame(
|
return _buildAvatarFrame(
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
|
||||||
@@ -86,8 +86,16 @@ class ContactAvatar extends StatelessWidget {
|
|||||||
bool get _usesSquareShape =>
|
bool get _usesSquareShape =>
|
||||||
contact.type == ContactType.channel || contact.type == ContactType.room;
|
contact.type == ContactType.channel || contact.type == ContactType.room;
|
||||||
|
|
||||||
|
bool get _showsLeadingEmoji {
|
||||||
|
final emoji = contact.roleEmoji;
|
||||||
|
if (emoji == null || emoji.isEmpty) return false;
|
||||||
|
|
||||||
|
final effectiveName = (displayName ?? contact.displayName).trimLeft();
|
||||||
|
return effectiveName.startsWith(emoji);
|
||||||
|
}
|
||||||
|
|
||||||
Color _getBackgroundColor(BuildContext context) {
|
Color _getBackgroundColor(BuildContext context) {
|
||||||
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
|
if (_shouldUseLabelFallback || _showsLeadingEmoji) {
|
||||||
return TrailColorService.getTrailColor(contact);
|
return TrailColorService.getTrailColor(contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -25,6 +26,10 @@ class _ConnectionModeSelectorState extends State<ConnectionModeSelector> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadLocalIPs() async {
|
Future<void> _loadLocalIPs() async {
|
||||||
|
if (kIsWeb) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final Set<String> ipsSet = {};
|
final Set<String> ipsSet = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
394
lib/widgets/contacts/contact_route_dialog.dart
Normal file
394
lib/widgets/contacts/contact_route_dialog.dart
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../models/contact.dart';
|
||||||
|
import '../../providers/app_provider.dart';
|
||||||
|
import '../../services/route_hash_preferences.dart';
|
||||||
|
|
||||||
|
class ContactRouteDialogResult {
|
||||||
|
final ParsedContactRoute? route;
|
||||||
|
final bool shouldClear;
|
||||||
|
|
||||||
|
const ContactRouteDialogResult._({this.route, required this.shouldClear});
|
||||||
|
|
||||||
|
const ContactRouteDialogResult.set(ParsedContactRoute route)
|
||||||
|
: this._(route: route, shouldClear: false);
|
||||||
|
|
||||||
|
const ContactRouteDialogResult.clear() : this._(shouldClear: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactRouteDialog extends StatefulWidget {
|
||||||
|
final Contact contact;
|
||||||
|
final List<Contact> availableContacts;
|
||||||
|
|
||||||
|
const ContactRouteDialog({
|
||||||
|
super.key,
|
||||||
|
required this.contact,
|
||||||
|
required this.availableContacts,
|
||||||
|
});
|
||||||
|
|
||||||
|
static Future<ContactRouteDialogResult?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required Contact contact,
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<ContactRouteDialogResult>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||||
|
),
|
||||||
|
child: ContactRouteDialog(
|
||||||
|
contact: contact,
|
||||||
|
availableContacts: availableContacts,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ContactRouteDialog> createState() => _ContactRouteDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||||
|
late final TextEditingController _controller;
|
||||||
|
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
||||||
|
ParsedContactRoute? _parsedRoute;
|
||||||
|
String? _errorText;
|
||||||
|
bool _showRoutingInfo = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController(
|
||||||
|
text: widget.contact.routeCanonicalText,
|
||||||
|
);
|
||||||
|
_controller.addListener(_reparse);
|
||||||
|
_loadHashSizePreference();
|
||||||
|
_reparse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller
|
||||||
|
..removeListener(_reparse)
|
||||||
|
..dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reparse() {
|
||||||
|
final input = _controller.text.trim();
|
||||||
|
if (input.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_parsedRoute = null;
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final parsed = ContactRouteCodec.parse(
|
||||||
|
input,
|
||||||
|
expectedHashSize: _selectedHashSize,
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_parsedRoute = parsed;
|
||||||
|
_errorText = null;
|
||||||
|
});
|
||||||
|
} on ContactRouteFormatException catch (error) {
|
||||||
|
setState(() {
|
||||||
|
_parsedRoute = null;
|
||||||
|
_errorText = error.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHashSizePreference() async {
|
||||||
|
final hashSize = await RouteHashPreferences.getHashSize();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_selectedHashSize = hashSize;
|
||||||
|
});
|
||||||
|
_reparse();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _tokenFor(Contact contact, int hashSize) {
|
||||||
|
final hex = contact.publicKeyHex.toUpperCase();
|
||||||
|
final length = hashSize * 2;
|
||||||
|
if (hex.length < length) {
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
return hex.substring(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _appendHop(Contact contact) {
|
||||||
|
final token = _tokenFor(contact, _selectedHashSize);
|
||||||
|
final current = _controller.text.trim();
|
||||||
|
_controller.text = current.isEmpty ? token : '$current,$token';
|
||||||
|
_controller.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: _controller.text.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final appProvider = context.watch<AppProvider>();
|
||||||
|
final routeCandidates =
|
||||||
|
widget.availableContacts
|
||||||
|
.where((contact) => contact.isRepeater || contact.isRoom)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||||
|
|
||||||
|
return FractionallySizedBox(
|
||||||
|
heightFactor: 0.85,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Set Route for ${widget.contact.displayName}',
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(
|
||||||
|
controller: _controller,
|
||||||
|
textCapitalization: TextCapitalization.characters,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Route',
|
||||||
|
hintText: _selectedHashSize == 1
|
||||||
|
? 'AA,BB,CC'
|
||||||
|
: _selectedHashSize == 2
|
||||||
|
? 'AABB,CCDD'
|
||||||
|
: 'AABBCC,DDEEFF',
|
||||||
|
helperText:
|
||||||
|
'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
||||||
|
errorText: _errorText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_parsedRoute == null
|
||||||
|
? 'Preview: enter a route to validate it.'
|
||||||
|
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (_parsedRoute != null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SelectableText(
|
||||||
|
_parsedRoute!.canonicalText,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_AutomationRoutingInfo(
|
||||||
|
isExpanded: _showRoutingInfo,
|
||||||
|
onToggle: () {
|
||||||
|
setState(() {
|
||||||
|
_showRoutingInfo = !_showRoutingInfo;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
|
||||||
|
nearestRelayFallbackEnabled:
|
||||||
|
appProvider.nearestRelayFallbackEnabled,
|
||||||
|
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Pick hops from contacts',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (routeCandidates.isEmpty)
|
||||||
|
const Text(
|
||||||
|
'No repeater or room contacts are available for route building.',
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: routeCandidates.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final candidate = routeCandidates[index];
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(candidate.displayName),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () => _appendHop(candidate),
|
||||||
|
child: Text(
|
||||||
|
'Use ${_tokenFor(candidate, _selectedHashSize)}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
if (widget.contact.routeHasPath)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop(const ContactRouteDialogResult.clear()),
|
||||||
|
child: const Text('Clear Route'),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _parsedRoute == null
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop(ContactRouteDialogResult.set(_parsedRoute!)),
|
||||||
|
child: const Text('Set Route'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AutomationRoutingInfo extends StatelessWidget {
|
||||||
|
final bool isExpanded;
|
||||||
|
final VoidCallback onToggle;
|
||||||
|
final bool autoRouteRotationEnabled;
|
||||||
|
final bool nearestRelayFallbackEnabled;
|
||||||
|
final bool clearPathOnMaxRetry;
|
||||||
|
|
||||||
|
const _AutomationRoutingInfo({
|
||||||
|
required this.isExpanded,
|
||||||
|
required this.onToggle,
|
||||||
|
required this.autoRouteRotationEnabled,
|
||||||
|
required this.nearestRelayFallbackEnabled,
|
||||||
|
required this.clearPathOnMaxRetry,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: onToggle,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Automatic direct-send routing',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isExpanded) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Room/contact sends keep one selected path for the whole send chain, retry up to 5 total attempts with 1s, 2s, 4s, and 8s backoff, then try one final nearest repeater if everything else fails.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Public and channel broadcasts are not affected by this automation.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_InfoChip(
|
||||||
|
label: autoRouteRotationEnabled
|
||||||
|
? 'Auto route rotation on'
|
||||||
|
: 'Auto route rotation off',
|
||||||
|
icon: Icons.swap_horiz,
|
||||||
|
),
|
||||||
|
_InfoChip(
|
||||||
|
label: nearestRelayFallbackEnabled
|
||||||
|
? 'Nearest repeater fallback on'
|
||||||
|
: 'Nearest repeater fallback off',
|
||||||
|
icon: Icons.router,
|
||||||
|
),
|
||||||
|
_InfoChip(
|
||||||
|
label: clearPathOnMaxRetry
|
||||||
|
? 'Clear path on max retry on'
|
||||||
|
: 'Clear path on max retry off',
|
||||||
|
icon: Icons.route,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Shows retry, rotation, and final repeater fallback behavior.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _InfoChip({required this.label, required this.icon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 14),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,452 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
|
||||||
import 'package:latlong2/latlong.dart';
|
|
||||||
import '../../models/contact.dart';
|
|
||||||
import '../../models/message.dart';
|
|
||||||
import '../../providers/connection_provider.dart';
|
|
||||||
import '../../providers/messages_provider.dart';
|
|
||||||
import '../../providers/app_provider.dart';
|
|
||||||
import '../../utils/toast_logger.dart';
|
|
||||||
import '../../l10n/app_localizations.dart';
|
|
||||||
|
|
||||||
class DirectMessageSheet extends StatefulWidget {
|
|
||||||
final Contact contact;
|
|
||||||
|
|
||||||
const DirectMessageSheet({super.key, required this.contact});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<DirectMessageSheet> createState() => _DirectMessageSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DirectMessageSheetState extends State<DirectMessageSheet> {
|
|
||||||
final TextEditingController _textController = TextEditingController();
|
|
||||||
final FocusNode _focusNode = FocusNode();
|
|
||||||
int _characterCount = 0;
|
|
||||||
static const int _maxCharacters = 160;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_textController.addListener(_updateCharacterCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_textController.dispose();
|
|
||||||
_focusNode.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updateCharacterCount() {
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() {
|
|
||||||
_characterCount = _textController.text.length;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert current GPS location at cursor position
|
|
||||||
Future<void> _insertCurrentLocation() async {
|
|
||||||
try {
|
|
||||||
// Check location permission
|
|
||||||
LocationPermission permission = await Geolocator.checkPermission();
|
|
||||||
if (permission == LocationPermission.denied) {
|
|
||||||
permission = await Geolocator.requestPermission();
|
|
||||||
if (permission == LocationPermission.denied) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ToastLogger.error(context, 'Location permission denied');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (permission == LocationPermission.deniedForever) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ToastLogger.error(context, 'Location permission permanently denied');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current position
|
|
||||||
final position = await Geolocator.getCurrentPosition(
|
|
||||||
locationSettings: const LocationSettings(
|
|
||||||
accuracy: LocationAccuracy.best,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Format location text
|
|
||||||
final locationText =
|
|
||||||
'📍 Lat: ${position.latitude.toStringAsFixed(5)}, Lon: ${position.longitude.toStringAsFixed(5)}';
|
|
||||||
|
|
||||||
// Check if adding location would exceed limit
|
|
||||||
final currentText = _textController.text;
|
|
||||||
if (currentText.length + locationText.length > _maxCharacters) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ToastLogger.error(
|
|
||||||
context,
|
|
||||||
'Adding location would exceed 160 character limit',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert at cursor position or append
|
|
||||||
final selection = _textController.selection;
|
|
||||||
final newText = currentText.replaceRange(
|
|
||||||
selection.start >= 0 ? selection.start : currentText.length,
|
|
||||||
selection.end >= 0 ? selection.end : currentText.length,
|
|
||||||
locationText,
|
|
||||||
);
|
|
||||||
|
|
||||||
_textController.text = newText;
|
|
||||||
|
|
||||||
// Move cursor to end of inserted text
|
|
||||||
final newCursorPosition =
|
|
||||||
(selection.start >= 0 ? selection.start : currentText.length) +
|
|
||||||
locationText.length;
|
|
||||||
_textController.selection = TextSelection.fromPosition(
|
|
||||||
TextPosition(offset: newCursorPosition),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ToastLogger.error(context, 'Failed to get location: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _sendDirectMessage() async {
|
|
||||||
final text = _textController.text.trim();
|
|
||||||
if (text.isEmpty) return;
|
|
||||||
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
|
||||||
final messagesProvider = context.read<MessagesProvider>();
|
|
||||||
|
|
||||||
if (!connectionProvider.deviceInfo.isConnected) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ToastLogger.error(
|
|
||||||
context,
|
|
||||||
AppLocalizations.of(context)!.notConnectedToDevice,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create message ID
|
|
||||||
final messageId = '${DateTime.now().millisecondsSinceEpoch}_dm_sent';
|
|
||||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
|
||||||
|
|
||||||
// Get current device's public key (first 6 bytes)
|
|
||||||
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
|
|
||||||
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
|
|
||||||
|
|
||||||
// Create sent message object with recipient public key for retry support
|
|
||||||
final sentMessage = Message(
|
|
||||||
id: messageId,
|
|
||||||
messageType: MessageType.contact,
|
|
||||||
senderPublicKeyPrefix: senderPublicKeyPrefix,
|
|
||||||
pathLen: 0,
|
|
||||||
textType: MessageTextType.plain,
|
|
||||||
senderTimestamp: timestamp,
|
|
||||||
text: text,
|
|
||||||
receivedAt: DateTime.now(),
|
|
||||||
deliveryStatus: MessageDeliveryStatus.sending,
|
|
||||||
recipientPublicKey:
|
|
||||||
widget.contact.publicKey, // Store recipient for retry
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add to messages list with "sending" status
|
|
||||||
// Pass contact for retry logic
|
|
||||||
messagesProvider.addSentMessage(sentMessage, contact: widget.contact);
|
|
||||||
|
|
||||||
// Send direct message to contact (include contact for path logging)
|
|
||||||
final sentSuccessfully = await connectionProvider.sendTextMessage(
|
|
||||||
contactPublicKey: widget.contact.publicKey,
|
|
||||||
text: text,
|
|
||||||
messageId: messageId, // Pass message ID for tracking
|
|
||||||
contact: widget.contact,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!sentSuccessfully) {
|
|
||||||
// Mark message as failed if sending failed
|
|
||||||
messagesProvider.markMessageFailed(messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
_textController.clear();
|
|
||||||
_focusNode.unfocus();
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
Navigator.pop(context); // Close the dialog
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
ToastLogger.error(
|
|
||||||
context,
|
|
||||||
AppLocalizations.of(context)!.failedToSend(e.toString()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final appProvider = context.watch<AppProvider>();
|
|
||||||
final isSimpleMode = appProvider.isSimpleMode;
|
|
||||||
final contactLocation = widget.contact.displayLocation;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
height: MediaQuery.of(context).size.height * 0.9,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surface,
|
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// Header
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: const BorderRadius.vertical(
|
|
||||||
top: Radius.circular(20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)!.directMessage,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
widget.contact.displayName,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 48), // Spacer to keep title centered
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Mini map in simple mode (scrollable content)
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (isSimpleMode && contactLocation != null) ...[
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
// Hide keyboard when tapping on map
|
|
||||||
_focusNode.unfocus();
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
height: 200,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: colorScheme.outline),
|
|
||||||
),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: FlutterMap(
|
|
||||||
options: MapOptions(
|
|
||||||
initialCenter: LatLng(
|
|
||||||
contactLocation.latitude,
|
|
||||||
contactLocation.longitude,
|
|
||||||
),
|
|
||||||
initialZoom: 13.0,
|
|
||||||
interactionOptions: const InteractionOptions(
|
|
||||||
flags:
|
|
||||||
InteractiveFlag.pinchZoom |
|
|
||||||
InteractiveFlag.drag,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
TileLayer(
|
|
||||||
urlTemplate:
|
|
||||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
||||||
userAgentPackageName: 'com.meshcore.sar',
|
|
||||||
),
|
|
||||||
MarkerLayer(
|
|
||||||
markers: [
|
|
||||||
Marker(
|
|
||||||
point: LatLng(
|
|
||||||
contactLocation.latitude,
|
|
||||||
contactLocation.longitude,
|
|
||||||
),
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
child: Icon(
|
|
||||||
Icons.location_on,
|
|
||||||
color: colorScheme.primary,
|
|
||||||
size: 40,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// Location coordinates
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.gps_fixed,
|
|
||||||
size: 14,
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'${contactLocation.latitude.toStringAsFixed(5)}, ${contactLocation.longitude.toStringAsFixed(5)}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontSize: 12,
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Message input
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
top: 16,
|
|
||||||
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextField(
|
|
||||||
controller: _textController,
|
|
||||||
focusNode: _focusNode,
|
|
||||||
maxLength: _maxCharacters,
|
|
||||||
maxLines: 3,
|
|
||||||
autofocus: true,
|
|
||||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
|
||||||
style: TextStyle(color: colorScheme.onSurface),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: AppLocalizations.of(context)!.typeYourMessage,
|
|
||||||
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(color: colorScheme.outline),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(color: colorScheme.outline),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: colorScheme.primary,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
contentPadding: const EdgeInsets.all(16),
|
|
||||||
counterText: '', // Hide default counter
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.send,
|
|
||||||
onSubmitted: (_) => _sendDirectMessage(),
|
|
||||||
),
|
|
||||||
// Always-visible character counter
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 4,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'$_characterCount / $_maxCharacters',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: _characterCount > 155
|
|
||||||
? Colors.red
|
|
||||||
: (_characterCount > 140
|
|
||||||
? Colors.orange
|
|
||||||
: colorScheme.onSurfaceVariant),
|
|
||||||
fontWeight: _characterCount > 140
|
|
||||||
? FontWeight.bold
|
|
||||||
: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// Location and Send buttons
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: _insertCurrentLocation,
|
|
||||||
icon: const Icon(Icons.my_location, size: 18),
|
|
||||||
label: Text(AppLocalizations.of(context)!.myLocation),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
side: BorderSide(color: colorScheme.outline),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: _textController.text.trim().isEmpty
|
|
||||||
? null
|
|
||||||
: _sendDirectMessage,
|
|
||||||
icon: const Icon(Icons.send),
|
|
||||||
label: Text(
|
|
||||||
AppLocalizations.of(context)!.sendDirectMessage,
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
||||||
backgroundColor: colorScheme.primary,
|
|
||||||
foregroundColor: colorScheme.onPrimary,
|
|
||||||
disabledBackgroundColor:
|
|
||||||
colorScheme.surfaceContainerHighest,
|
|
||||||
disabledForegroundColor: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
|
||||||
|
|
||||||
/// Overlay widget that displays controls for download area selection.
|
|
||||||
/// The actual polygon should be rendered inside FlutterMap's children.
|
|
||||||
class DownloadAreaOverlay extends StatelessWidget {
|
|
||||||
final LatLngBounds bounds;
|
|
||||||
final VoidCallback onConfirm;
|
|
||||||
final VoidCallback onCancel;
|
|
||||||
|
|
||||||
const DownloadAreaOverlay({
|
|
||||||
super.key,
|
|
||||||
required this.bounds,
|
|
||||||
required this.onConfirm,
|
|
||||||
required this.onCancel,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
// Control buttons at the top
|
|
||||||
Positioned(
|
|
||||||
top: 16,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
child: Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Download Area Selection',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'The blue rectangle shows the area to be downloaded. '
|
|
||||||
'To change the area, tap Cancel and select download again.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: onCancel,
|
|
||||||
icon: const Icon(Icons.close),
|
|
||||||
label: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: onConfirm,
|
|
||||||
icon: const Icon(Icons.check),
|
|
||||||
label: const Text('Confirm'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Area info at the bottom
|
|
||||||
Positioned(
|
|
||||||
bottom: 16,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
child: Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Area Bounds',
|
|
||||||
style: Theme.of(context).textTheme.titleSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'N: ${bounds.north.toStringAsFixed(4)}° '
|
|
||||||
'S: ${bounds.south.toStringAsFixed(4)}°',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'E: ${bounds.east.toStringAsFixed(4)}° '
|
|
||||||
'W: ${bounds.west.toStringAsFixed(4)}°',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user