mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
136 Commits
v2026.0306
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e502bc23b7 | ||
|
|
7be1460ef6 | ||
|
|
30c273c656 | ||
|
|
5712feba53 | ||
|
|
6de12225fc | ||
|
|
6e9e9e397d | ||
|
|
a3147e9d7c | ||
|
|
5e1912d9f2 | ||
|
|
b42328bd82 | ||
|
|
1b801a10ef | ||
|
|
06632ee32d | ||
|
|
e7a481f97b | ||
|
|
9f31f50e61 | ||
|
|
d4237b7569 | ||
|
|
b35c11c804 | ||
|
|
be2cf53374 | ||
|
|
13a729492f | ||
|
|
9d8594fe05 | ||
|
|
249268dd00 | ||
|
|
63aa9619e6 | ||
|
|
a7ad4bb8ec | ||
|
|
636e114428 | ||
|
|
d5684c51c7 | ||
|
|
ced39b6992 | ||
|
|
083b715039 | ||
|
|
36584be0ab | ||
|
|
dc19296804 | ||
|
|
40072175e3 | ||
|
|
11f3bf9f97 | ||
|
|
24e715d01e | ||
|
|
6e17de3866 | ||
|
|
90877a1891 | ||
|
|
77e43843bf | ||
|
|
1e70f52069 | ||
|
|
5e404944e9 | ||
|
|
53daf2c9a7 | ||
|
|
c36d63a0ad | ||
|
|
69ec2dfdb5 | ||
|
|
d2e6b692f3 | ||
|
|
baa1aa6edd | ||
|
|
38cb3dc031 | ||
|
|
abb73a4c65 | ||
|
|
2d1e6c0ca4 | ||
|
|
b38a8c55d6 | ||
|
|
ca872f2049 | ||
|
|
1b6ff59af3 | ||
|
|
e31bfac2ac | ||
|
|
99bb446ee9 | ||
|
|
088f3d37ef | ||
|
|
7f200e5334 | ||
|
|
ae16417886 | ||
|
|
467371946d | ||
|
|
2d23328ad0 | ||
|
|
0dfb84bf4e | ||
|
|
193a193e8a | ||
|
|
c1d1ec7d0c | ||
|
|
b415b63b4b | ||
|
|
818b9dbfb2 | ||
|
|
8085b89c07 | ||
|
|
86771a3c55 | ||
|
|
4b668ac2e9 | ||
|
|
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 | ||
|
|
30cac1bcbf | ||
|
|
95e6aa35e2 | ||
|
|
2bc0e21cf0 | ||
|
|
f6c5a3a4ca | ||
|
|
810897d348 | ||
|
|
bb1c1f455d | ||
|
|
b0eded3780 | ||
|
|
f1d56e2000 | ||
|
|
01afcd2ea7 | ||
|
|
70a6156e8a |
4
.github/workflows/build-artifacts.yml
vendored
4
.github/workflows/build-artifacts.yml
vendored
@@ -12,7 +12,7 @@ permissions:
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.35.6"
|
||||
FLUTTER_VERSION: "3.41.4"
|
||||
APP_NAME: meshcore-sar
|
||||
ANDROID_NDK_VERSION: "27.0.12077973"
|
||||
|
||||
@@ -280,7 +280,7 @@ jobs:
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build web release
|
||||
run: flutter build web --release --base-href /meshcore_sar_app/
|
||||
run: flutter build web --release --base-href /meshcore-sar/
|
||||
|
||||
- name: Upload pages artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
|
||||
2
.github/workflows/build-multiplatform.yml
vendored
2
.github/workflows/build-multiplatform.yml
vendored
@@ -10,7 +10,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: '3.35.6'
|
||||
FLUTTER_VERSION: '3.41.4'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -80,10 +80,11 @@ create_feature_graphic.py
|
||||
# Tool caches and local state
|
||||
.cachebro/
|
||||
.osgrep/
|
||||
third_party/lpcnet_flutter/
|
||||
|
||||
# Claude Code local settings (permissions, personal config)
|
||||
.claude/settings.local.json
|
||||
|
||||
# Dart code coverage
|
||||
coverage/
|
||||
lcov.info
|
||||
lcov.info
|
||||
|
||||
13
CHANGELOG.md
Normal file
13
CHANGELOG.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-08 to 2026-03-14
|
||||
|
||||
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.
|
||||
- Messaging and channels: fixed channel sync, deduplication, overwrite and deletion issues, cleared message input after send, and added delayed-message warning tracking.
|
||||
- Voice and transport: improved voice playback quality, improved scan and advert parsing, and kept route path data visible in logs.
|
||||
- Maps and field operations: enabled iOS background GPS updates, added GPX trail import/export, introduced a fullscreen repeater map, and added live mesh traffic views.
|
||||
- Localization and UI polish: added multilingual localization coverage, updated locale strings, fixed onboarding layout issues, and refined channel and contacts presentation.
|
||||
|
||||
## Key PRs
|
||||
|
||||
- [#16](https://github.com/dz0ny/meshcore-sar/pull/16) Merge pull request #16 from `MGJ520/main`
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
MeshCore SAR helps teams coordinate in low-connectivity or no-connectivity environments with messaging, voice, images, maps, and live location context in one app.
|
||||
It uses the MeshCore protocol over LoRa for long-range, infrastructure-free communication.
|
||||
`iOS TestFlight:` https://testflight.apple.com/join/HhzerdHp
|
||||
`iOS TestFlight:` https://testflight.apple.com/join/nCZYMPPz
|
||||
|
||||
## Highlights
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
<!-- Microphone for voice messages -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<!-- Notifications -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Image mode mirrors the voice on-demand architecture exactly:
|
||||
Image mode mirrors the voice on-demand architecture exactly, including
|
||||
swarm-assisted recovery for stalled partial transfers:
|
||||
|
||||
- **Control plane (text messages):**
|
||||
- `IE2:` image envelope announces image availability in chat.
|
||||
- `IE4:` image envelope announces image availability in chat.
|
||||
- **Control plane (raw binary request):**
|
||||
- Binary image fetch request (same raw route as image fragments).
|
||||
- **Data plane (raw binary packets):**
|
||||
@@ -14,11 +15,14 @@ Image mode mirrors the voice on-demand architecture exactly:
|
||||
Images are never broadcast in full to channels. Chat carries only metadata;
|
||||
pixels are fetched on demand when the user taps the image bubble.
|
||||
|
||||
Shared swarm fallback is documented in
|
||||
[Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
## 2. Key Modules
|
||||
|
||||
- `lib/utils/image_message_parser.dart`
|
||||
- `ImagePacket` (binary fragment format)
|
||||
- `ImageEnvelope` (`IE2`)
|
||||
- `ImageEnvelope` (`IE4`)
|
||||
- `ImageFetchRequest` (binary)
|
||||
- `fragmentImage()` — split compressed bytes into packets
|
||||
- `reassembleImage()` — join received fragments into bytes
|
||||
@@ -27,8 +31,9 @@ pixels are fetched on demand when the user taps the image bubble.
|
||||
- `lib/providers/image_provider.dart`
|
||||
- Reassembly sessions, outgoing cache, deferred serving
|
||||
- Outgoing sessions also registered as complete incoming sessions for immediate local display
|
||||
- Received partial sessions can be re-served during swarm recovery
|
||||
- `lib/providers/app_provider.dart`
|
||||
- Incoming routing for `IE2`, binary image fetch requests, binary `0x49` packets
|
||||
- Incoming routing for `IE4`, binary image fetch requests, binary `0x49` packets, and raw swarm control payloads
|
||||
- `lib/widgets/messages/image_message_bubble.dart`
|
||||
- Square cover thumbnail (up to 256 px); tap-to-load for received images;
|
||||
progress ring during fetch; full-screen `InteractiveViewer` on tap
|
||||
@@ -40,9 +45,9 @@ pixels are fetched on demand when the user taps the image bubble.
|
||||
|
||||
## 3. Wire Formats
|
||||
|
||||
### 3.1 Image Envelope (`IE2`)
|
||||
### 3.1 Image Envelope (`IE4`)
|
||||
|
||||
Prefix: `IE2:` + colon-delimited compact payload (base36 numeric fields)
|
||||
Prefix: `IE4:` + colon-delimited compact payload (base36 numeric fields)
|
||||
|
||||
Fields:
|
||||
|
||||
@@ -54,51 +59,50 @@ Fields:
|
||||
| `w` | base36 | Actual image width after compression (pixels) |
|
||||
| `h` | base36 | Actual image height after compression (pixels) |
|
||||
| `bytes` | base36 | Total compressed size in bytes |
|
||||
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
|
||||
| `ts` | base36 | Unix timestamp (seconds) |
|
||||
|
||||
Compact format:
|
||||
|
||||
```text
|
||||
IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}
|
||||
IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
|
||||
```
|
||||
|
||||
Example (256×171 landscape image, 14 fragments):
|
||||
|
||||
```text
|
||||
IE2:a:0:e:74:4r:1mc:aabbccddeeff:s44we8
|
||||
IE4:a:0:e:74:4r:1mc
|
||||
```
|
||||
|
||||
Note: `sid` is base36 on wire and expands to 8-hex internally.
|
||||
`w` and `h` reflect the actual post-compression dimensions, which preserve
|
||||
the source aspect ratio (contain within the configured max size).
|
||||
|
||||
### 3.2 Image Fetch Request (binary)
|
||||
### 3.2 Image Fetch Request (`IR4` + binary)
|
||||
|
||||
Text format:
|
||||
|
||||
```text
|
||||
IR4:{sid}:{want}:{requesterKey6}
|
||||
```
|
||||
|
||||
Binary payload format:
|
||||
|
||||
```text
|
||||
[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
|
||||
[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
|
||||
```
|
||||
|
||||
| Field | Value |
|
||||
|------------------|--------------------------|
|
||||
| `flags` | bit0=1 => request missing indices, else all |
|
||||
| `requesterKey6` | 6-byte requester key prefix |
|
||||
| `ts` | unix timestamp seconds (u32) |
|
||||
|
||||
### 3.3 Raw Image Packet (data plane)
|
||||
|
||||
Binary payload structure:
|
||||
|
||||
- Byte 0: magic `0x49` (`'I'`)
|
||||
- Bytes 1..4: session ID (4 bytes)
|
||||
- Byte 5: format ID
|
||||
- Byte 6: fragment index (0-based)
|
||||
- Byte 7: total fragments
|
||||
- Bytes 8..N: image data (max 152 bytes per fragment)
|
||||
- Byte 5: fragment index (0-based)
|
||||
- Bytes 6..N: image data (max 152 bytes per fragment)
|
||||
|
||||
Header is 8 bytes — identical layout to `VoicePacket`.
|
||||
Header is 6 bytes. Image format and total fragment count come from the `IE4` envelope.
|
||||
|
||||
## 4. Compression Pipeline
|
||||
|
||||
@@ -152,11 +156,11 @@ only the shorter axis is padded — no cropping occurs.
|
||||
7. Envelope sent via normal message path:
|
||||
- Channel: `sendChannelMessage`
|
||||
- Direct: `sendTextMessage`
|
||||
8. Local placeholder message added (`IE2:` text, `deliveryStatus.sending`).
|
||||
8. Local placeholder message added (`IE4:` text, `deliveryStatus.sending`).
|
||||
|
||||
## 6. Incoming Flow (Receive)
|
||||
|
||||
### 6.1 `IE2` envelope received
|
||||
### 6.1 `IE4` envelope received
|
||||
|
||||
`AppProvider` calls `imageProvider.registerEnvelope()` and adds the message to
|
||||
chat. The bubble shows a grey square placeholder with a download icon.
|
||||
@@ -165,11 +169,21 @@ chat. The bubble shows a grey square placeholder with a download icon.
|
||||
|
||||
`AppProvider` treats it as control-plane only (not added to chat):
|
||||
|
||||
- Validates requester key prefix.
|
||||
- Resolves requester contact.
|
||||
- Calls `imageProvider.serveSessionTo()` which streams all cached fragments.
|
||||
|
||||
### 6.3 Raw packet received (`pushRawData`, magic `0x49`)
|
||||
### 6.3 Swarm control messages received
|
||||
|
||||
`AppProvider` also handles shared raw swarm discovery payloads:
|
||||
|
||||
- binary swarm requests advertise which image fragments are still missing
|
||||
- binary swarm availability responses advertise which fragments another peer can relay
|
||||
- swarm payloads arrive via `pushRawData` and are intercepted instead of being added to chat
|
||||
|
||||
Shared discovery and responder semantics are documented in
|
||||
[Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
### 6.4 Raw packet received (`pushRawData`, magic `0x49`)
|
||||
|
||||
`AppProvider.onRawDataReceived` parses `ImagePacket` binary and calls
|
||||
`imageProvider.addFragment()`. When the session becomes complete, the bubble
|
||||
@@ -188,6 +202,9 @@ When `cacheOutgoingSession()` is called it also writes all fragments into
|
||||
`_sessions[sessionId]`, so the sender sees the image immediately in the bubble
|
||||
(no tap-to-load required for own messages).
|
||||
|
||||
If a peer later receives only part of an image session, those received fragments
|
||||
can also be served onward to another requester during swarm recovery.
|
||||
|
||||
## 8. Display
|
||||
|
||||
`ImageMessageBubble` (max width 256 px):
|
||||
@@ -195,7 +212,8 @@ When `cacheOutgoingSession()` is called it also writes all fragments into
|
||||
- **Complete session**: `AspectRatio(1.0)` → `AvifImage.memory(fit: cover)`
|
||||
square thumbnail; tap → full-screen `InteractiveViewer` with fade transition.
|
||||
- **Incomplete/missing**: grey square placeholder with download icon;
|
||||
tap → sends binary fetch request.
|
||||
tap → sends binary fetch request, with raw swarm fallback if the original
|
||||
sender path stalls.
|
||||
- **Loading**: circular progress indicator showing `received/total` count.
|
||||
- **Error**: broken-image icon.
|
||||
|
||||
@@ -213,12 +231,12 @@ Image bubbles and Message Technical Details show an **estimated transmit time**
|
||||
The estimate is airtime-based (LoRa packet model), not just compressed image size:
|
||||
|
||||
- Source inputs:
|
||||
- `total` fragments and `bytes` from `IE2` envelope
|
||||
- `total` fragments and `bytes` from `IE4` envelope
|
||||
- all numeric envelope values are decoded from base36
|
||||
- `pathLen` from message metadata
|
||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||
- Per-fragment payload model:
|
||||
- `meshHeader(2)` + `pathLen` + `imageHeader(8)` + `fragmentBytes`
|
||||
- `meshHeader(2)` + `pathLen` + `imageHeader(6)` + `fragmentBytes`
|
||||
- LoRa airtime:
|
||||
- standard symbol-time formula (preamble + payload symbols)
|
||||
- Mesh pacing/hops:
|
||||
@@ -246,9 +264,12 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
|
||||
## 11. Operational Constraints
|
||||
|
||||
- No firmware changes required (reuses `cmdSendRawData` / `pushRawData`).
|
||||
- On-demand fetch works only if sender app is online and has cached session.
|
||||
- On-demand fetch prefers the original sender, but a partial image can also be
|
||||
completed from alternate peers that already hold matching fragments.
|
||||
- Raw return path requires a valid direct route to requester.
|
||||
- Available on iOS and Android (`image_picker` + `flutter_avif`).
|
||||
- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as image
|
||||
fetch and fragment delivery.
|
||||
|
||||
### 11.1 Raw Binary Routing Semantics
|
||||
|
||||
@@ -260,7 +281,28 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
|
||||
- only nodes on that path relay it;
|
||||
- it is **not** received by everyone in the mesh.
|
||||
|
||||
## 12. High-Level Sequence
|
||||
## 12. Swarm Fallback Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Original Sender
|
||||
participant P as Peer With Fragments
|
||||
participant N as Reachable Peers
|
||||
participant B as Receiver App
|
||||
|
||||
A->>M: Send IE4 envelope
|
||||
M->>B: Deliver IE4
|
||||
B->>A: Direct binary image fetch request
|
||||
A->>B: Stream raw ImagePacket fragments (partial)
|
||||
Note over A,B: Sender path stops responding
|
||||
B->>N: Raw swarm requests with missing image fragment indices
|
||||
P->>B: Raw swarm availability response
|
||||
B->>P: Direct binary fetch request for missing subset
|
||||
P->>B: Stream remaining raw ImagePacket fragments
|
||||
B->>B: Reassemble completed image
|
||||
```
|
||||
|
||||
## 13. High-Level Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
@@ -272,8 +314,8 @@ sequenceDiagram
|
||||
A->>A: Compress: contain resize → grayscale → PNG → AVIF
|
||||
A->>A: Fragment into ≤152B packets
|
||||
A->>A: Cache outgoing + populate local session (immediate display)
|
||||
A->>M: Send IE2 envelope (actual w×h, fragment count)
|
||||
M->>B: Deliver IE2
|
||||
A->>M: Send IE4 envelope (actual w×h, fragment count)
|
||||
M->>B: Deliver IE4
|
||||
B->>B: Render grey placeholder bubble
|
||||
B->>A: Tap → send binary fetch request
|
||||
A->>B: Stream binary ImagePackets
|
||||
|
||||
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
|
||||
|
||||
Voice mode uses a **two-plane architecture**:
|
||||
Voice mode uses a **two-plane architecture** with optional swarm-assisted
|
||||
recovery:
|
||||
|
||||
- **Control plane (text messages):**
|
||||
- `VE2:` voice envelope announces voice availability in chat.
|
||||
- `VE3:` voice envelope announces voice availability in chat.
|
||||
- **Control plane (raw binary request):**
|
||||
- Binary voice fetch request (same raw route as voice packets).
|
||||
- **Data plane (raw binary packets):**
|
||||
@@ -13,11 +14,13 @@ Voice mode uses a **two-plane architecture**:
|
||||
|
||||
This design avoids broadcasting full voice payloads to channels/rooms. Chat carries only metadata; audio is fetched on demand when user presses play.
|
||||
|
||||
Swarm fallback is documented in [Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
## 2. Key Modules
|
||||
|
||||
- `lib/utils/voice_message_parser.dart`
|
||||
- `VoicePacket` (legacy text + binary packet format)
|
||||
- `VoiceEnvelope` (`VE2`)
|
||||
- `VoicePacket` (binary direct-packet format)
|
||||
- `VoiceEnvelope` (`VE3`)
|
||||
- `VoiceFetchRequest` (binary)
|
||||
- `lib/screens/messages_tab.dart`
|
||||
- Capture/encode voice, cache encoded packets, send envelope only
|
||||
@@ -25,20 +28,20 @@ This design avoids broadcasting full voice payloads to channels/rooms. Chat carr
|
||||
- Reassembly/playback sessions
|
||||
- Outgoing session cache + deferred serving
|
||||
- `lib/providers/app_provider.dart`
|
||||
- Incoming routing for `VE2` and binary voice fetch requests
|
||||
- Incoming routing for `VE3`, binary voice fetch requests, and raw swarm control payloads
|
||||
- Handles raw packet ingestion
|
||||
- `lib/widgets/messages/voice_message_bubble.dart`
|
||||
- Play behavior (immediate play if complete, otherwise fetch + auto-play)
|
||||
- `lib/providers/messages_provider.dart`
|
||||
- Message-level voice detection (`VE2` + legacy `V:`)
|
||||
- Message-level voice detection (`VE3`)
|
||||
- `lib/services/message_storage_service.dart`
|
||||
- Persists `isVoice` and `voiceId`
|
||||
|
||||
## 3. Wire Formats
|
||||
|
||||
### 3.1 Voice Envelope (`VE2`)
|
||||
### 3.1 Voice Envelope (`VE3`)
|
||||
|
||||
Prefix: `VE2:` + colon-delimited compact payload (base36 numeric fields)
|
||||
Prefix: `VE3:` + colon-delimited compact payload (base36 numeric fields)
|
||||
|
||||
Fields:
|
||||
|
||||
@@ -46,21 +49,19 @@ Fields:
|
||||
- `mode` (base36): codec mode ID (`VoicePacketMode.id`)
|
||||
- `total` (base36): packet count (1..255)
|
||||
- `durS` (base36): estimated duration in seconds
|
||||
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
|
||||
- `ts` (base36): unix timestamp seconds
|
||||
|
||||
`sid` is base36 on wire and expands to 8-hex internally.
|
||||
|
||||
Compact format:
|
||||
|
||||
```text
|
||||
VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
|
||||
VE3:{sid}:{mode}:{total}:{durS}
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
VE2:a:1:4:4:aabbccddeeff:s44we8
|
||||
VE3:a:1:4:4
|
||||
```
|
||||
|
||||
### 3.2 Voice Fetch Request (binary)
|
||||
@@ -68,7 +69,7 @@ VE2:a:1:4:4:aabbccddeeff:s44we8
|
||||
Binary payload format:
|
||||
|
||||
```text
|
||||
[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][ts:4B][missingCount:1B][missingIndices...]
|
||||
[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
|
||||
```
|
||||
|
||||
### 3.3 Raw Voice Packet (data plane)
|
||||
@@ -77,10 +78,10 @@ Binary payload structure:
|
||||
|
||||
- Byte 0: magic `0x56` (`'V'`)
|
||||
- Bytes 1..4: session ID (4 bytes)
|
||||
- Byte 5: mode ID
|
||||
- Byte 6: packet index
|
||||
- Byte 7: total packets
|
||||
- Bytes 8..N: codec2 data
|
||||
- Byte 5: packet index
|
||||
- Bytes 6..N: codec2 data
|
||||
|
||||
Header is 6 bytes. Mode and total packet count come from the `VE3` envelope.
|
||||
|
||||
## 4. Outgoing Flow (Send)
|
||||
|
||||
@@ -88,27 +89,38 @@ Binary payload structure:
|
||||
2. Each chunk is codec2-encoded into `VoicePacket` objects.
|
||||
3. Packets are cached in `VoiceProvider` outgoing cache (TTL 15 min).
|
||||
4. Sender inserts local voice placeholder message (`isVoice=true`, `voiceId=sessionId`).
|
||||
5. Sender sends one envelope (`VE2`) through normal message path:
|
||||
5. Sender sends one envelope (`VE3`) through normal message path:
|
||||
- channel/room: `sendChannelMessage`
|
||||
- direct: `sendTextMessage`
|
||||
6. **No raw audio packets are sent during initial send.**
|
||||
|
||||
## 5. Incoming Routing
|
||||
|
||||
### 5.1 `VE2` envelope received
|
||||
### 5.1 `VE3` envelope received
|
||||
|
||||
`AppProvider` marks message as voice (`isVoice`, `voiceId`) and adds it to chat.
|
||||
`AppProvider` records sender identity from message metadata, registers the voice
|
||||
session envelope, marks the message as voice (`isVoice`, `voiceId`), and adds it to chat.
|
||||
|
||||
### 5.2 Binary voice fetch request received
|
||||
|
||||
`AppProvider` treats it as control-plane only:
|
||||
|
||||
- request is not added to chat
|
||||
- validates requester prefix match against sender metadata
|
||||
- resolves requester contact via key prefix
|
||||
- calls `voiceProvider.serveSessionTo(...)`
|
||||
|
||||
### 5.3 Raw packet received (`pushRawData`)
|
||||
### 5.3 Swarm control messages received
|
||||
|
||||
`AppProvider` also handles raw swarm control payloads:
|
||||
|
||||
- binary swarm requests advertise which voice fragments are still missing
|
||||
- binary swarm availability responses advertise which fragments a peer can relay
|
||||
- swarm control payloads arrive via `pushRawData` and are not added to chat history
|
||||
|
||||
Swarm semantics are shared with image mode and documented in
|
||||
[Swarm Mode Technical Design](./swarm-mode-technical.md).
|
||||
|
||||
### 5.4 Raw packet received (`pushRawData`)
|
||||
|
||||
`AppProvider.onRawDataReceived` parses `VoicePacket` binary and appends to session in `VoiceProvider`.
|
||||
|
||||
@@ -118,10 +130,15 @@ In `VoiceMessageBubble`:
|
||||
|
||||
- If session already complete: play immediately.
|
||||
- If incomplete/missing:
|
||||
1. Resolve sender contact (message sender prefix or `VE2.senderKey6` fallback)
|
||||
2. Send direct binary fetch request
|
||||
3. Show requesting state in UI
|
||||
4. Auto-play when session becomes complete
|
||||
1. Resolve sender contact from message sender metadata
|
||||
2. Prefer a direct fetch from the original sender if its raw route is healthy
|
||||
3. If the sender path does not respond, fan out a raw swarm request with the
|
||||
exact missing packet indices to reachable peers
|
||||
4. Wait up to 10 seconds for raw peer availability responses
|
||||
5. Send a direct binary fetch request to the best alternate peer for the
|
||||
missing subset it advertised
|
||||
6. Show requesting state in UI
|
||||
7. Auto-play when session becomes complete
|
||||
|
||||
If sender cannot be resolved or request cannot be sent, bubble remains and shows: **"Voice unavailable right now"**.
|
||||
|
||||
@@ -136,10 +153,13 @@ If sender cannot be resolved or request cannot be sent, bubble remains and shows
|
||||
|
||||
Serving prerequisites:
|
||||
|
||||
- session exists in cache
|
||||
- session exists in outgoing cache or already-received session state
|
||||
- `sendRawPacketCallback` configured
|
||||
- requester has direct path (`outPathLen >= 0`)
|
||||
|
||||
Received partial sessions can therefore act as relay sources during swarm
|
||||
recovery.
|
||||
|
||||
## 8. Persistence
|
||||
|
||||
`MessageStorageService` now stores and restores:
|
||||
@@ -167,13 +187,13 @@ Voice bubbles and Message Technical Details show an **estimated transmit time**
|
||||
The estimate is airtime-based (LoRa packet model), not file-duration-only:
|
||||
|
||||
- Source inputs:
|
||||
- `packetCount` and `durationMs` from `VE2` envelope, or
|
||||
- `packetCount` and `durationMs` from `VE3` envelope, or
|
||||
- numeric envelope values decoded from base36
|
||||
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
|
||||
- `pathLen` from message metadata
|
||||
- current radio params from `deviceInfo`: `radioBw`, `radioSf`, `radioCr`
|
||||
- Per-packet payload model:
|
||||
- `meshHeader(2)` + `pathLen` + `voiceHeader(8)` + `codec2Bytes`
|
||||
- `meshHeader(2)` + `pathLen` + `voiceHeader(6)` + `codec2Bytes`
|
||||
- LoRa airtime:
|
||||
- standard symbol-time formula (preamble + payload symbols)
|
||||
- Mesh pacing/hops:
|
||||
@@ -192,9 +212,12 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
|
||||
## 11. Operational Constraints
|
||||
|
||||
- No firmware changes required.
|
||||
- On-demand fetch works only if sender app is online and has cached session.
|
||||
- On-demand fetch prefers the original sender, but a partial session can also be
|
||||
completed from alternate peers that already hold packets.
|
||||
- Raw return path needs a currently valid direct route to requester.
|
||||
- Voice capture is available on iOS and Android (`Platform.isIOS || Platform.isAndroid`).
|
||||
- Swarm discovery uses the same `cmdSendRawData` / `pushRawData` path as voice
|
||||
fetch and packet delivery.
|
||||
|
||||
### 11.1 Raw Binary Routing Semantics
|
||||
|
||||
@@ -206,26 +229,27 @@ Fallback defaults are used when radio params are unavailable: `SF10`, `BW250kHz`
|
||||
- only nodes on that path relay it;
|
||||
- it is **not** received by everyone in the mesh.
|
||||
|
||||
## 12. Backward Compatibility
|
||||
|
||||
- Legacy `V:` text packet parsing is still supported.
|
||||
- Message voice detection accepts `VE2` and legacy `V:` formats.
|
||||
|
||||
## 13. High-Level Sequence
|
||||
## 12. High-Level Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Sender App
|
||||
participant M as Mesh Chat
|
||||
participant P as Peer With Packets
|
||||
participant N as Reachable Peers
|
||||
participant B as Receiver App
|
||||
|
||||
A->>A: Record + encode voice packets
|
||||
A->>A: Cache session packets (TTL 15m)
|
||||
A->>M: Send VE2 envelope
|
||||
M->>B: Deliver VE2
|
||||
A->>M: Send VE3 envelope
|
||||
M->>B: Deliver VE3
|
||||
B->>B: Render voice bubble (metadata only)
|
||||
B->>A: Send binary fetch request on Play
|
||||
A->>B: Stream raw VoicePacket packets
|
||||
A->>B: Stream raw VoicePacket packets (partial)
|
||||
Note over A,B: Sender path stops responding
|
||||
B->>N: Raw swarm requests with missing voice packet indices
|
||||
P->>B: Raw swarm availability response
|
||||
B->>P: Direct binary fetch request for missing subset
|
||||
P->>B: Stream remaining raw VoicePacket packets
|
||||
B->>B: Reassemble session
|
||||
B->>B: Auto-play when complete
|
||||
```
|
||||
|
||||
@@ -59,10 +59,6 @@ PODS:
|
||||
- Flutter
|
||||
- nsd_ios (0.0.1):
|
||||
- Flutter
|
||||
- ObjectBox (4.4.1)
|
||||
- objectbox_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
- ObjectBox (= 4.4.1)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@@ -103,7 +99,6 @@ DEPENDENCIES:
|
||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- nsd_ios (from `.symlinks/plugins/nsd_ios/ios`)
|
||||
- objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
@@ -118,7 +113,6 @@ SPEC REPOS:
|
||||
trunk:
|
||||
- DKImagePickerController
|
||||
- DKPhotoGallery
|
||||
- ObjectBox
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
|
||||
@@ -149,8 +143,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
nsd_ios:
|
||||
:path: ".symlinks/plugins/nsd_ios/ios"
|
||||
objectbox_flutter_libs:
|
||||
:path: ".symlinks/plugins/objectbox_flutter_libs/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
@@ -171,7 +163,7 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/vibration/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd
|
||||
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
||||
codec2_flutter: 15e24fa897d9d903a2afb1cc5a17ae3ac88b6d6f
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
@@ -182,12 +174,10 @@ SPEC CHECKSUMS:
|
||||
flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e
|
||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||
flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1
|
||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
|
||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
nsd_ios: 596ad79109ddd3e52d665f650f36428049e3653e
|
||||
ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757
|
||||
objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
|
||||
@@ -489,7 +489,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
CURRENT_PROJECT_VERSION = 115;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -511,7 +511,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
CURRENT_PROJECT_VERSION = 115;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -530,7 +530,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
CURRENT_PROJECT_VERSION = 115;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -547,7 +547,7 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
CURRENT_PROJECT_VERSION = 115;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
@@ -679,7 +679,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
CURRENT_PROJECT_VERSION = 115;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
@@ -702,7 +702,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CURRENT_PROJECT_VERSION = 92;
|
||||
CURRENT_PROJECT_VERSION = 115;
|
||||
DEVELOPMENT_TEAM = JND55328G8;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import UIKit
|
||||
import Flutter
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -8,6 +9,7 @@ import Flutter
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,9 +43,11 @@
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>92</string>
|
||||
<string>115</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
@@ -100,6 +102,7 @@
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>location</string>
|
||||
<string>bluetooth-central</string>
|
||||
<string>bluetooth-peripheral</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
|
||||
@@ -5,22 +5,22 @@
|
||||
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000216">
|
||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000235">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.451844">
|
||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.385323">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="110.228647">
|
||||
<testcase classname="fastlane.lanes" name="2: build_app" time="93.530304">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="475.510001">
|
||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="210.562484">
|
||||
|
||||
</testcase>
|
||||
|
||||
|
||||
1452
lib/l10n/app_de.arb
1452
lib/l10n/app_de.arb
File diff suppressed because it is too large
Load Diff
2673
lib/l10n/app_el.arb
2673
lib/l10n/app_el.arb
File diff suppressed because it is too large
Load Diff
1969
lib/l10n/app_en.arb
1969
lib/l10n/app_en.arb
File diff suppressed because it is too large
Load Diff
1441
lib/l10n/app_es.arb
1441
lib/l10n/app_es.arb
File diff suppressed because it is too large
Load Diff
1446
lib/l10n/app_fr.arb
1446
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
1446
lib/l10n/app_it.arb
1446
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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1927
lib/l10n/app_localizations_pl.dart
Normal file
1927
lib/l10n/app_localizations_pl.dart
Normal file
File diff suppressed because it is too large
Load Diff
1934
lib/l10n/app_localizations_pt.dart
Normal file
1934
lib/l10n/app_localizations_pt.dart
Normal file
File diff suppressed because it is too large
Load Diff
1924
lib/l10n/app_localizations_ru.dart
Normal file
1924
lib/l10n/app_localizations_ru.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1917
lib/l10n/app_localizations_tr.dart
Normal file
1917
lib/l10n/app_localizations_tr.dart
Normal file
File diff suppressed because it is too large
Load Diff
1930
lib/l10n/app_localizations_uk.dart
Normal file
1930
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
2332
lib/l10n/app_pl.arb
Normal file
2332
lib/l10n/app_pl.arb
Normal file
File diff suppressed because it is too large
Load Diff
2332
lib/l10n/app_pt.arb
Normal file
2332
lib/l10n/app_pt.arb
Normal file
File diff suppressed because it is too large
Load Diff
567
lib/l10n/app_ru.arb
Normal file
567
lib/l10n/app_ru.arb
Normal file
@@ -0,0 +1,567 @@
|
||||
{
|
||||
"@@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": "Запрос голоса",
|
||||
"device": "устройство",
|
||||
"change": "Изменить",
|
||||
"wizardOverviewDescription": "Это приложение объединяет сообщения MeshCore, полевые обновления SAR, карты и инструменты устройства в одном месте.",
|
||||
"wizardOverviewFeature1": "Отправляйте личные сообщения, сообщения комнат и сообщения каналов из основной вкладки «Сообщения».",
|
||||
"wizardOverviewFeature2": "Делитесь SAR-маркерами, рисунками на карте, голосовыми клипами и изображениями через mesh-сеть.",
|
||||
"wizardOverviewFeature3": "Подключайтесь по BLE или TCP, а затем управляйте сопутствующей радиостанцией прямо из приложения.",
|
||||
"wizardMessagingTitle": "Сообщения и полевые отчёты",
|
||||
"wizardMessagingDescription": "Здесь сообщения это больше, чем просто текст. Приложение уже поддерживает несколько видов операционных данных и сценариев передачи.",
|
||||
"wizardMessagingFeature1": "Отправляйте личные сообщения, сообщения комнат и трафик каналов из одного редактора.",
|
||||
"wizardMessagingFeature2": "Создавайте обновления SAR и повторно используемые шаблоны SAR для типовых полевых отчётов.",
|
||||
"wizardMessagingFeature3": "Передавайте голосовые сессии и изображения с индикатором прогресса и оценкой эфирного времени в интерфейсе.",
|
||||
"wizardConnectDeviceTitle": "Подключить устройство",
|
||||
"wizardConnectDeviceDescription": "Подключите свою радиостанцию MeshCore, выберите имя и примените радиопрофиль перед продолжением.",
|
||||
"wizardSetupBadge": "Настройка",
|
||||
"wizardOverviewBadge": "Обзор",
|
||||
"wizardConnectedToDevice": "Подключено к {deviceName}",
|
||||
"wizardNoDeviceConnected": "Устройство ещё не подключено",
|
||||
"wizardSkipForNow": "Пропустить пока",
|
||||
"wizardDeviceNameLabel": "Имя устройства",
|
||||
"wizardDeviceNameHelp": "Это имя транслируется другим пользователям MeshCore.",
|
||||
"wizardConfigRegionLabel": "Регион конфигурации",
|
||||
"wizardConfigRegionHelp": "Используется полный официальный список профилей MeshCore. По умолчанию выбран EU/UK (Narrow).",
|
||||
"wizardPresetNote1": "Убедитесь, что выбранный профиль соответствует местным радиоправилам.",
|
||||
"wizardPresetNote2": "Список соответствует официальному потоку профилей инструмента MeshCore config.",
|
||||
"wizardPresetNote3": "Для онбординга по умолчанию остаётся выбран EU/UK (Narrow).",
|
||||
"wizardSaving": "Сохранение...",
|
||||
"wizardSaveAndContinue": "Сохранить и продолжить",
|
||||
"wizardEnterDeviceName": "Введите имя устройства перед продолжением.",
|
||||
"wizardDeviceSetupSaved": "Сохранено: {deviceName}, профиль {presetName}.",
|
||||
"wizardNetworkTitle": "Контакты, комнаты и ретрансляторы",
|
||||
"wizardNetworkDescription": "Вкладка «Контакты» организует сеть, которую вы обнаруживаете, и маршруты, которые вы узнаёте со временем.",
|
||||
"wizardNetworkFeature1": "Просматривайте участников команды, ретрансляторы, комнаты, каналы и ожидающие объявления в одном списке.",
|
||||
"wizardNetworkFeature2": "Используйте smart ping, вход в комнаты, изученные пути и инструменты сброса маршрутов, когда связь становится нестабильной.",
|
||||
"wizardNetworkFeature3": "Создавайте каналы и управляйте сетевыми назначениями, не покидая приложение.",
|
||||
"wizardMapOpsTitle": "Карта, следы и общая геометрия",
|
||||
"wizardMapOpsDescription": "Карта приложения напрямую связана с сообщениями, отслеживанием и SAR-слоями, а не является отдельным просмотрщиком.",
|
||||
"wizardMapOpsFeature1": "Отслеживайте свою позицию, местоположение команды и траектории движения на карте.",
|
||||
"wizardMapOpsFeature2": "Открывайте рисунки из сообщений, просматривайте их прямо в интерфейсе и удаляйте с карты при необходимости.",
|
||||
"wizardMapOpsFeature3": "Используйте карты ретрансляторов и общие слои, чтобы понимать покрытие сети в поле.",
|
||||
"wizardToolsTitle": "Инструменты помимо сообщений",
|
||||
"wizardToolsDescription": "Здесь есть больше, чем четыре основные вкладки. Приложение также включает настройку, диагностику и необязательные сценарии датчиков.",
|
||||
"wizardToolsFeature1": "Откройте настройки устройства, чтобы изменить параметры радио, телеметрию, мощность TX и сведения о сопутствующем устройстве.",
|
||||
"wizardToolsFeature2": "Включите вкладку «Датчики», если нужны панели наблюдаемых датчиков и быстрые действия обновления.",
|
||||
"wizardToolsFeature3": "Используйте журналы пакетов, сканирование спектра и диагностику разработчика для устранения проблем mesh-сети.",
|
||||
"contactInSensors": "В датчиках",
|
||||
"contactAddToSensors": "Добавить в датчики",
|
||||
"contactSetPath": "Задать путь",
|
||||
"contactAddedToSensors": "{contactName} добавлен в Датчики",
|
||||
"contactFailedToClearRoute": "Не удалось очистить маршрут: {error}",
|
||||
"contactRouteCleared": "Маршрут очищен",
|
||||
"contactRouteSet": "Маршрут задан: {route}",
|
||||
"contactFailedToSetRoute": "Не удалось задать маршрут: {error}"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
2332
lib/l10n/app_tr.arb
Normal file
2332
lib/l10n/app_tr.arb
Normal file
File diff suppressed because it is too large
Load Diff
2332
lib/l10n/app_uk.arb
Normal file
2332
lib/l10n/app_uk.arb
Normal file
File diff suppressed because it is too large
Load Diff
2393
lib/l10n/app_zh.arb
2393
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_localizations/flutter_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -14,13 +17,15 @@ import 'providers/channels_provider.dart';
|
||||
import 'providers/voice_provider.dart';
|
||||
import 'providers/image_provider.dart' as ip;
|
||||
import 'providers/app_provider.dart';
|
||||
import 'providers/sensors_provider.dart';
|
||||
import 'services/voice_codec_service.dart';
|
||||
import 'services/voice_player_service.dart';
|
||||
import 'services/tile_cache_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'services/locale_preferences.dart';
|
||||
import 'services/mesh_map_nodes_service.dart';
|
||||
import 'services/update_checker_service.dart';
|
||||
import 'services/wizard_preferences.dart';
|
||||
import 'screens/discovery_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/welcome_wizard_screen.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
@@ -38,11 +43,13 @@ class MeshCoreSarApp extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||
AppThemeMode _themeMode = AppThemeMode.system;
|
||||
Locale? _locale;
|
||||
bool _isInitialized = false;
|
||||
bool _shouldShowPermissionDialog = false;
|
||||
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
||||
String? _pendingNotificationPayload;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -62,6 +69,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
|
||||
// Set up notification tap handler for update notifications
|
||||
NotificationService().onNotificationTapped = _handleNotificationTap;
|
||||
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
|
||||
|
||||
// Check if we need to request location permissions
|
||||
await _checkLocationPermissions();
|
||||
@@ -70,10 +78,21 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
// Shows notification if update is available
|
||||
_checkForUpdates();
|
||||
|
||||
// Refresh remote mesh node cache in background when the app starts.
|
||||
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
|
||||
|
||||
setState(() {
|
||||
_wizardCompleted = wizardCompleted;
|
||||
_isInitialized = true;
|
||||
});
|
||||
|
||||
if (_pendingNotificationPayload != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final payload = _pendingNotificationPayload;
|
||||
_pendingNotificationPayload = null;
|
||||
_handleNotificationTap(payload);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle notification tap
|
||||
@@ -82,10 +101,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
|
||||
debugPrint('[Main] Notification tapped: $payload');
|
||||
|
||||
if (!_wizardCompleted) {
|
||||
_pendingNotificationPayload = payload;
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle update notification tap
|
||||
if (payload.startsWith('update:')) {
|
||||
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
||||
_launchUpdateDownload(downloadUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.startsWith('discovery:')) {
|
||||
final navigator = _navigatorKey.currentState;
|
||||
if (navigator == null) {
|
||||
_pendingNotificationPayload = payload;
|
||||
return;
|
||||
}
|
||||
navigator.push(
|
||||
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
|
||||
);
|
||||
}
|
||||
// SAR and message notifications handled by their respective providers
|
||||
}
|
||||
@@ -158,7 +194,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
/// Shows notification if update is available
|
||||
Future<void> _checkForUpdates() async {
|
||||
// Only check for updates on Android
|
||||
if (!Platform.isAndroid) {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
debugPrint('[UpdateChecker] Skipping update check (not Android)');
|
||||
return;
|
||||
}
|
||||
@@ -234,6 +270,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
},
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||
|
||||
// Voice provider (packet reassembly + playback)
|
||||
ChangeNotifierProvider(
|
||||
@@ -246,18 +283,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
// Image provider (fragment reassembly + outgoing session cache)
|
||||
ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
|
||||
|
||||
// Tile cache service
|
||||
Provider(create: (_) => TileCacheService()),
|
||||
|
||||
// App provider that coordinates everything
|
||||
// VoiceProvider is read via context.read inside create since it's already registered above
|
||||
ChangeNotifierProxyProvider6<
|
||||
ChangeNotifierProxyProvider5<
|
||||
ConnectionProvider,
|
||||
ContactsProvider,
|
||||
MessagesProvider,
|
||||
DrawingProvider,
|
||||
ChannelsProvider,
|
||||
TileCacheService,
|
||||
AppProvider
|
||||
>(
|
||||
create: (context) => AppProvider(
|
||||
@@ -268,7 +301,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
channelsProvider: context.read<ChannelsProvider>(),
|
||||
voiceProvider: context.read<VoiceProvider>(),
|
||||
imageProvider: context.read<ip.ImageProvider>(),
|
||||
tileCacheService: context.read<TileCacheService>(),
|
||||
),
|
||||
update:
|
||||
(
|
||||
@@ -278,7 +310,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
messages,
|
||||
drawings,
|
||||
channels,
|
||||
tileCache,
|
||||
previous,
|
||||
) =>
|
||||
previous ??
|
||||
@@ -290,7 +321,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
channelsProvider: channels,
|
||||
voiceProvider: context.read<VoiceProvider>(),
|
||||
imageProvider: context.read<ip.ImageProvider>(),
|
||||
tileCacheService: tileCache,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -303,6 +333,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
builder: (context) {
|
||||
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
||||
final materialApp = MaterialApp(
|
||||
navigatorKey: _navigatorKey,
|
||||
key: ValueKey<String?>(
|
||||
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
||||
),
|
||||
@@ -330,7 +361,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||
|
||||
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
|
||||
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
|
||||
if (Platform.isAndroid) {
|
||||
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
||||
return SafeArea(
|
||||
left: false,
|
||||
right: false,
|
||||
|
||||
@@ -1,10 +1,186 @@
|
||||
export 'package:meshcore_client/meshcore_client.dart'
|
||||
show Contact, ContactType, ContactTelemetry, AdvertLocation;
|
||||
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
|
||||
class ParsedContactRoute {
|
||||
final String canonicalText;
|
||||
final int hashSize;
|
||||
final int hopCount;
|
||||
final int encodedPathLen;
|
||||
final int signedEncodedPathLen;
|
||||
final Uint8List pathBytes;
|
||||
final Uint8List paddedPathBytes;
|
||||
|
||||
const ParsedContactRoute({
|
||||
required this.canonicalText,
|
||||
required this.hashSize,
|
||||
required this.hopCount,
|
||||
required this.encodedPathLen,
|
||||
required this.signedEncodedPathLen,
|
||||
required this.pathBytes,
|
||||
required this.paddedPathBytes,
|
||||
});
|
||||
|
||||
int get byteLength => pathBytes.length;
|
||||
String get summary => hopCount == 0
|
||||
? 'Direct'
|
||||
: '$hopCount hop${hopCount == 1 ? '' : 's'} via $hashSize-byte hashes';
|
||||
}
|
||||
|
||||
class ContactRouteFormatException implements Exception {
|
||||
final String message;
|
||||
|
||||
const ContactRouteFormatException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class ContactRouteCodec {
|
||||
static const int maxHashSize = 3;
|
||||
static const int maxPathBytes = 64;
|
||||
static const int _unknownDescriptor = 0xFF;
|
||||
|
||||
static ParsedContactRoute parse(String input, {int? expectedHashSize}) {
|
||||
final normalized = input.trim().toUpperCase();
|
||||
if (normalized.isEmpty) {
|
||||
throw const ContactRouteFormatException('Route cannot be empty.');
|
||||
}
|
||||
|
||||
if (expectedHashSize != null &&
|
||||
(expectedHashSize < 1 || expectedHashSize > maxHashSize)) {
|
||||
throw const ContactRouteFormatException(
|
||||
'Hash size must be 1, 2, or 3 bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
final hopTokens = normalized
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.toList();
|
||||
if (hopTokens.any((token) => token.isEmpty)) {
|
||||
throw const ContactRouteFormatException('Route contains an empty hop.');
|
||||
}
|
||||
|
||||
final hopBytes = <List<int>>[];
|
||||
int? hashSize;
|
||||
for (final token in hopTokens) {
|
||||
final compact = token.replaceAll(':', '');
|
||||
if (compact.isEmpty || !RegExp(r'^[0-9A-F]+$').hasMatch(compact)) {
|
||||
throw ContactRouteFormatException('Invalid hop "$token".');
|
||||
}
|
||||
if (compact.length.isOdd) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must contain full bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
final currentHashSize = compact.length ~/ 2;
|
||||
if (currentHashSize < 1 || currentHashSize > maxHashSize) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must be 1, 2, or 3 bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedHashSize != null && currentHashSize != expectedHashSize) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must be $expectedHashSize '
|
||||
'byte${expectedHashSize == 1 ? '' : 's'}.',
|
||||
);
|
||||
}
|
||||
|
||||
hashSize ??= currentHashSize;
|
||||
if (hashSize != currentHashSize) {
|
||||
throw const ContactRouteFormatException(
|
||||
'All hops in a route must use the same hash size.',
|
||||
);
|
||||
}
|
||||
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; i < compact.length; i += 2) {
|
||||
bytes.add(int.parse(compact.substring(i, i + 2), radix: 16));
|
||||
}
|
||||
hopBytes.add(bytes);
|
||||
}
|
||||
|
||||
final resolvedHashSize = hashSize ?? 1;
|
||||
final flatBytes = Uint8List.fromList(
|
||||
hopBytes.expand((hop) => hop).toList(),
|
||||
);
|
||||
if (flatBytes.length > maxPathBytes) {
|
||||
throw const ContactRouteFormatException(
|
||||
'Route exceeds the 64-byte firmware limit.',
|
||||
);
|
||||
}
|
||||
|
||||
final encodedPathLen =
|
||||
((resolvedHashSize - 1) << 6) | (hopBytes.length & 0x3F);
|
||||
final padded = Uint8List(maxPathBytes);
|
||||
padded.setRange(0, flatBytes.length, flatBytes);
|
||||
|
||||
return ParsedContactRoute(
|
||||
canonicalText: hopBytes
|
||||
.map(
|
||||
(hop) => hop
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase(),
|
||||
)
|
||||
.join(','),
|
||||
hashSize: resolvedHashSize,
|
||||
hopCount: hopBytes.length,
|
||||
encodedPathLen: encodedPathLen,
|
||||
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
|
||||
pathBytes: flatBytes,
|
||||
paddedPathBytes: padded,
|
||||
);
|
||||
}
|
||||
|
||||
static ParsedContactRoute? fromContact(Contact contact) {
|
||||
if (!contact.routeHasPath || contact.routeHopCount == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ParsedContactRoute(
|
||||
canonicalText: contact.routeCanonicalText,
|
||||
hashSize: contact.routeHashSize,
|
||||
hopCount: contact.routeHopCount,
|
||||
encodedPathLen: contact.routeEncodedPathLen,
|
||||
signedEncodedPathLen: contact.routeSignedPathLen,
|
||||
pathBytes: contact.routePathBytes,
|
||||
paddedPathBytes: _padPath(contact.routePathBytes),
|
||||
);
|
||||
}
|
||||
|
||||
static Uint8List _padPath(Uint8List bytes) {
|
||||
final padded = Uint8List(maxPathBytes);
|
||||
padded.setRange(0, math.min(bytes.length, maxPathBytes), bytes);
|
||||
return padded;
|
||||
}
|
||||
|
||||
static int toSignedDescriptor(int encodedPathLen) =>
|
||||
encodedPathLen > 127 ? encodedPathLen - 256 : encodedPathLen;
|
||||
|
||||
static int toUnsignedDescriptor(int signedPathLen) => signedPathLen & 0xFF;
|
||||
|
||||
static bool isUnknownDescriptor(int signedPathLen) =>
|
||||
toUnsignedDescriptor(signedPathLen) == _unknownDescriptor;
|
||||
|
||||
static bool isValidDescriptor(int signedPathLen) {
|
||||
final raw = toUnsignedDescriptor(signedPathLen);
|
||||
if (raw == _unknownDescriptor) return false;
|
||||
final hashSize = ((raw >> 6) + 1);
|
||||
if (hashSize > maxHashSize) return false;
|
||||
final hopCount = raw & 0x3F;
|
||||
return hopCount * hashSize <= maxPathBytes;
|
||||
}
|
||||
}
|
||||
|
||||
extension ContactLocalization on Contact {
|
||||
/// Returns the localized display name for special contacts (e.g. Public Channel).
|
||||
/// For all other contacts, returns [displayName].
|
||||
@@ -14,4 +190,55 @@ extension ContactLocalization on Contact {
|
||||
}
|
||||
return displayName;
|
||||
}
|
||||
|
||||
int get routeEncodedPathLen =>
|
||||
ContactRouteCodec.toUnsignedDescriptor(outPathLen);
|
||||
|
||||
int get routeSignedPathLen =>
|
||||
ContactRouteCodec.toSignedDescriptor(routeEncodedPathLen);
|
||||
|
||||
bool get routeIsUnknown => ContactRouteCodec.isUnknownDescriptor(outPathLen);
|
||||
|
||||
bool get routeHasPath =>
|
||||
!routeIsUnknown && ContactRouteCodec.isValidDescriptor(outPathLen);
|
||||
|
||||
int get routeHashSize => routeHasPath ? ((routeEncodedPathLen >> 6) + 1) : 1;
|
||||
|
||||
int get routeHopCount => routeHasPath ? (routeEncodedPathLen & 0x3F) : -1;
|
||||
|
||||
int get routeByteLength => routeHasPath
|
||||
? math.min(routeHopCount * routeHashSize, outPath.length)
|
||||
: 0;
|
||||
|
||||
Uint8List get routePathBytes => routeByteLength <= 0
|
||||
? Uint8List(0)
|
||||
: Uint8List.fromList(outPath.sublist(0, routeByteLength));
|
||||
|
||||
String get routeCanonicalText {
|
||||
if (!routeHasPath || routeHopCount <= 0) return '';
|
||||
final bytes = routePathBytes;
|
||||
final hops = <String>[];
|
||||
for (var i = 0; i < bytes.length; i += routeHashSize) {
|
||||
hops.add(
|
||||
bytes
|
||||
.sublist(i, i + routeHashSize)
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toUpperCase(),
|
||||
);
|
||||
}
|
||||
return hops.join(',');
|
||||
}
|
||||
|
||||
String get routeSummary {
|
||||
if (routeIsUnknown) {
|
||||
return 'Flood/Unknown';
|
||||
}
|
||||
if (!routeHasPath || routeHopCount == 0) {
|
||||
return 'Direct';
|
||||
}
|
||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||
}
|
||||
|
||||
bool get routeSupportsLegacyRawTransport => routeHasPath;
|
||||
}
|
||||
|
||||
39
lib/models/contact_group.dart
Normal file
39
lib/models/contact_group.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
class SavedContactGroup {
|
||||
final String id;
|
||||
final String sectionKey;
|
||||
final String label;
|
||||
final String query;
|
||||
final DateTime createdAt;
|
||||
final List<String>? matchPrefixes;
|
||||
final bool isAutoGroup;
|
||||
|
||||
const SavedContactGroup({
|
||||
required this.id,
|
||||
required this.sectionKey,
|
||||
required this.label,
|
||||
required this.query,
|
||||
required this.createdAt,
|
||||
this.matchPrefixes,
|
||||
this.isAutoGroup = false,
|
||||
});
|
||||
|
||||
SavedContactGroup copyWith({
|
||||
String? id,
|
||||
String? sectionKey,
|
||||
String? label,
|
||||
String? query,
|
||||
DateTime? createdAt,
|
||||
List<String>? matchPrefixes,
|
||||
bool? isAutoGroup,
|
||||
}) {
|
||||
return SavedContactGroup(
|
||||
id: id ?? this.id,
|
||||
sectionKey: sectionKey ?? this.sectionKey,
|
||||
label: label ?? this.label,
|
||||
query: query ?? this.query,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
|
||||
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,11 @@ class DeviceInfo {
|
||||
final int? advLat;
|
||||
final int? advLon;
|
||||
final bool? manualAddContacts;
|
||||
final bool? autoAddUsers;
|
||||
final bool? autoAddRepeaters;
|
||||
final bool? autoAddRoomServers;
|
||||
final bool? autoAddSensors;
|
||||
final bool? autoAddOverwriteOldest;
|
||||
final int? radioFreq;
|
||||
final int? radioBw;
|
||||
final int? radioSf;
|
||||
@@ -113,6 +118,11 @@ class DeviceInfo {
|
||||
this.advLat,
|
||||
this.advLon,
|
||||
this.manualAddContacts,
|
||||
this.autoAddUsers,
|
||||
this.autoAddRepeaters,
|
||||
this.autoAddRoomServers,
|
||||
this.autoAddSensors,
|
||||
this.autoAddOverwriteOldest,
|
||||
this.radioFreq,
|
||||
this.radioBw,
|
||||
this.radioSf,
|
||||
@@ -239,6 +249,11 @@ class DeviceInfo {
|
||||
int? advLat,
|
||||
int? advLon,
|
||||
bool? manualAddContacts,
|
||||
bool? autoAddUsers,
|
||||
bool? autoAddRepeaters,
|
||||
bool? autoAddRoomServers,
|
||||
bool? autoAddSensors,
|
||||
bool? autoAddOverwriteOldest,
|
||||
int? radioFreq,
|
||||
int? radioBw,
|
||||
int? radioSf,
|
||||
@@ -278,6 +293,12 @@ class DeviceInfo {
|
||||
advLat: advLat ?? this.advLat,
|
||||
advLon: advLon ?? this.advLon,
|
||||
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
|
||||
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
|
||||
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
|
||||
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
|
||||
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
|
||||
autoAddOverwriteOldest:
|
||||
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
|
||||
radioFreq: radioFreq ?? this.radioFreq,
|
||||
radioBw: radioBw ?? this.radioBw,
|
||||
radioSf: radioSf ?? this.radioSf,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -10,6 +9,7 @@ enum MapLayerType {
|
||||
googleHybrid,
|
||||
googleRoadmap,
|
||||
googleTerrain,
|
||||
// Kept for stored preference compatibility after MBTiles removal.
|
||||
vectorMbtiles,
|
||||
wmsBase,
|
||||
}
|
||||
@@ -21,13 +21,6 @@ class MapLayer {
|
||||
final String attribution;
|
||||
final double maxZoom;
|
||||
|
||||
// Vector tile specific properties
|
||||
final bool isVector;
|
||||
final File? mbtilesFile;
|
||||
final String? styleUrl;
|
||||
final String? sourceName;
|
||||
final bool? isGzipped;
|
||||
|
||||
// WMS specific properties
|
||||
final bool isWms;
|
||||
final String? wmsBaseUrl;
|
||||
@@ -43,11 +36,6 @@ class MapLayer {
|
||||
required this.urlTemplate,
|
||||
required this.attribution,
|
||||
required this.maxZoom,
|
||||
this.isVector = false,
|
||||
this.mbtilesFile,
|
||||
this.styleUrl,
|
||||
this.sourceName,
|
||||
this.isGzipped,
|
||||
this.isWms = false,
|
||||
this.wmsBaseUrl,
|
||||
this.wmsLayers,
|
||||
@@ -74,7 +62,7 @@ class MapLayer {
|
||||
case MapLayerType.googleTerrain:
|
||||
return localizations.googleTerrain;
|
||||
case MapLayerType.vectorMbtiles:
|
||||
// For vector tiles, use the name from metadata
|
||||
// Legacy value kept only for preference migration compatibility.
|
||||
return name;
|
||||
case MapLayerType.wmsBase:
|
||||
// For WMS layers, use the name (will be localized separately)
|
||||
@@ -182,28 +170,4 @@ class MapLayer {
|
||||
static MapLayer fromType(MapLayerType type) {
|
||||
return allLayers.firstWhere((layer) => layer.type == type);
|
||||
}
|
||||
|
||||
/// Create a MapLayer from an MBTiles file
|
||||
static MapLayer fromMbtilesFile({
|
||||
required String name,
|
||||
required File mbtilesFile,
|
||||
required String styleUrl,
|
||||
required String sourceName,
|
||||
required double maxZoom,
|
||||
required bool isGzipped,
|
||||
String? attribution,
|
||||
}) {
|
||||
return MapLayer(
|
||||
type: MapLayerType.vectorMbtiles,
|
||||
name: name,
|
||||
urlTemplate: '', // Not used for vector tiles
|
||||
attribution: attribution ?? 'MBTiles',
|
||||
maxZoom: maxZoom,
|
||||
isVector: true,
|
||||
mbtilesFile: mbtilesFile,
|
||||
styleUrl: styleUrl,
|
||||
sourceName: sourceName,
|
||||
isGzipped: isGzipped,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
140
lib/models/message_reception_details.dart
Normal file
140
lib/models/message_reception_details.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
const int _transmitEstimateToleranceMs = 1500;
|
||||
|
||||
int? sanitizeEstimatedTransmitMs({
|
||||
required int? estimatedTransmitMs,
|
||||
required int? senderToReceiptMs,
|
||||
}) {
|
||||
if (estimatedTransmitMs == null || estimatedTransmitMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (senderToReceiptMs == null || senderToReceiptMs <= 0) {
|
||||
return estimatedTransmitMs;
|
||||
}
|
||||
|
||||
// Sender timestamps are second-granularity, so allow a small cushion before
|
||||
// treating the estimate as impossible for the observed delivery time.
|
||||
if (estimatedTransmitMs > senderToReceiptMs + _transmitEstimateToleranceMs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return estimatedTransmitMs;
|
||||
}
|
||||
|
||||
class MessageReceptionDetails {
|
||||
final DateTime capturedAt;
|
||||
final DateTime? packetLoggedAt;
|
||||
final int? rssiDbm;
|
||||
final double? snrDb;
|
||||
final List<int>? pathBytes;
|
||||
final int? senderToReceiptMs;
|
||||
final int? estimatedTransmitMs;
|
||||
final int? postTransmitDelayMs;
|
||||
final int receivedCopies;
|
||||
|
||||
const MessageReceptionDetails({
|
||||
required this.capturedAt,
|
||||
this.packetLoggedAt,
|
||||
this.rssiDbm,
|
||||
this.snrDb,
|
||||
this.pathBytes,
|
||||
this.senderToReceiptMs,
|
||||
this.estimatedTransmitMs,
|
||||
this.postTransmitDelayMs,
|
||||
this.receivedCopies = 1,
|
||||
});
|
||||
|
||||
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
||||
? null
|
||||
: pathBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
|
||||
'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch,
|
||||
'rssiDbm': rssiDbm,
|
||||
'snrDb': snrDb,
|
||||
'pathBytes': pathBytes,
|
||||
'senderToReceiptMs': senderToReceiptMs,
|
||||
'estimatedTransmitMs': estimatedTransmitMs,
|
||||
'postTransmitDelayMs': postTransmitDelayMs,
|
||||
'receivedCopies': receivedCopies,
|
||||
};
|
||||
}
|
||||
|
||||
MessageReceptionDetails copyWith({
|
||||
DateTime? capturedAt,
|
||||
DateTime? packetLoggedAt,
|
||||
int? rssiDbm,
|
||||
double? snrDb,
|
||||
List<int>? pathBytes,
|
||||
int? senderToReceiptMs,
|
||||
int? estimatedTransmitMs,
|
||||
int? postTransmitDelayMs,
|
||||
int? receivedCopies,
|
||||
}) {
|
||||
return MessageReceptionDetails(
|
||||
capturedAt: capturedAt ?? this.capturedAt,
|
||||
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
|
||||
rssiDbm: rssiDbm ?? this.rssiDbm,
|
||||
snrDb: snrDb ?? this.snrDb,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
|
||||
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
|
||||
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
|
||||
receivedCopies: receivedCopies ?? this.receivedCopies,
|
||||
);
|
||||
}
|
||||
|
||||
static MessageReceptionDetails mergeDuplicate({
|
||||
MessageReceptionDetails? existing,
|
||||
MessageReceptionDetails? incoming,
|
||||
}) {
|
||||
final base =
|
||||
existing ??
|
||||
incoming ??
|
||||
MessageReceptionDetails(capturedAt: DateTime.now());
|
||||
|
||||
return base.copyWith(
|
||||
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
|
||||
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
|
||||
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
|
||||
snrDb: incoming?.snrDb ?? existing?.snrDb,
|
||||
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
|
||||
senderToReceiptMs:
|
||||
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
|
||||
estimatedTransmitMs:
|
||||
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
|
||||
postTransmitDelayMs:
|
||||
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
|
||||
receivedCopies:
|
||||
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
|
||||
);
|
||||
}
|
||||
|
||||
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
||||
final capturedAtMillis = json['capturedAtMillis'];
|
||||
if (capturedAtMillis is! int) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final pathBytes = json['pathBytes'];
|
||||
return MessageReceptionDetails(
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
|
||||
packetLoggedAt: json['packetLoggedAtMillis'] is int
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
json['packetLoggedAtMillis'] as int,
|
||||
)
|
||||
: null,
|
||||
rssiDbm: json['rssiDbm'] as int?,
|
||||
snrDb: (json['snrDb'] as num?)?.toDouble(),
|
||||
pathBytes: pathBytes is List
|
||||
? pathBytes.whereType<num>().map((b) => b.toInt()).toList()
|
||||
: null,
|
||||
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
||||
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
||||
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
||||
receivedCopies: json['receivedCopies'] as int? ?? 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
197
lib/models/path_history.dart
Normal file
197
lib/models/path_history.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
enum PathRecordSource { learned, observed }
|
||||
|
||||
class PathRecord {
|
||||
final List<int> pathBytes;
|
||||
final int hopCount;
|
||||
final int hashSize;
|
||||
final PathRecordSource source;
|
||||
final int successCount;
|
||||
final int failureCount;
|
||||
final int lastRoundTripTimeMs;
|
||||
final DateTime lastUsedAt;
|
||||
|
||||
const PathRecord({
|
||||
required this.pathBytes,
|
||||
required this.hopCount,
|
||||
required this.hashSize,
|
||||
required this.source,
|
||||
required this.successCount,
|
||||
required this.failureCount,
|
||||
required this.lastRoundTripTimeMs,
|
||||
required this.lastUsedAt,
|
||||
});
|
||||
|
||||
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,
|
||||
PathRecordSource? source,
|
||||
int? successCount,
|
||||
int? failureCount,
|
||||
int? lastRoundTripTimeMs,
|
||||
DateTime? lastUsedAt,
|
||||
}) {
|
||||
return PathRecord(
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
hopCount: hopCount ?? this.hopCount,
|
||||
hashSize: hashSize ?? this.hashSize,
|
||||
source: source ?? this.source,
|
||||
successCount: successCount ?? this.successCount,
|
||||
failureCount: failureCount ?? this.failureCount,
|
||||
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
|
||||
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'path_bytes': pathBytes,
|
||||
'hop_count': hopCount,
|
||||
'hash_size': hashSize,
|
||||
'source': source.name,
|
||||
'success_count': successCount,
|
||||
'failure_count': failureCount,
|
||||
'last_round_trip_time_ms': lastRoundTripTimeMs,
|
||||
'last_used_at': lastUsedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
source: PathRecordSource.values.firstWhere(
|
||||
(value) => value.name == (json['source'] as String? ?? 'learned'),
|
||||
orElse: () => PathRecordSource.learned,
|
||||
),
|
||||
successCount: json['success_count'] as int? ?? 0,
|
||||
failureCount: json['failure_count'] as int? ?? 0,
|
||||
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
|
||||
lastUsedAt:
|
||||
DateTime.tryParse(json['last_used_at'] as String? ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
List<PathRecord> get observedPaths => directPaths
|
||||
.where((record) => record.source == PathRecordSource.observed)
|
||||
.toList();
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ enum DrawingMode { none, line, rectangle, measure }
|
||||
/// Provider for managing map drawings
|
||||
class DrawingProvider with ChangeNotifier {
|
||||
static const String _storageKey = 'map_drawings';
|
||||
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
|
||||
static const String _showSarMarkersKey = 'map_show_sar_markers';
|
||||
|
||||
// Drawing state
|
||||
DrawingMode _drawingMode = DrawingMode.none;
|
||||
@@ -20,6 +22,7 @@ class DrawingProvider with ChangeNotifier {
|
||||
|
||||
// Completed drawings
|
||||
final List<MapDrawing> _drawings = [];
|
||||
bool _isInitialized = false;
|
||||
|
||||
// In-progress drawing
|
||||
MapDrawing? _currentDrawing;
|
||||
@@ -51,13 +54,16 @@ class DrawingProvider with ChangeNotifier {
|
||||
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
||||
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
||||
bool get isDrawing => _drawingMode != DrawingMode.none;
|
||||
bool get isInitialized => _isInitialized;
|
||||
LatLng? get measurementPoint1 => _measurementPoint1;
|
||||
LatLng? get measurementPoint2 => _measurementPoint2;
|
||||
double? get measuredDistance => _measuredDistance;
|
||||
|
||||
/// Initialize and load saved drawings
|
||||
Future<void> initialize() async {
|
||||
await _loadPreferences();
|
||||
await _loadDrawings();
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
/// Set drawing mode
|
||||
@@ -77,15 +83,30 @@ class DrawingProvider with ChangeNotifier {
|
||||
}
|
||||
|
||||
/// Toggle visibility of received drawings
|
||||
void toggleReceivedDrawings() {
|
||||
Future<void> toggleReceivedDrawings() async {
|
||||
_showReceivedDrawings = !_showReceivedDrawings;
|
||||
notifyListeners();
|
||||
await _savePreferences();
|
||||
}
|
||||
|
||||
/// Toggle visibility of SAR markers
|
||||
void toggleSarMarkers() {
|
||||
Future<void> toggleSarMarkers() async {
|
||||
_showSarMarkers = !_showSarMarkers;
|
||||
notifyListeners();
|
||||
await _savePreferences();
|
||||
}
|
||||
|
||||
Future<void> _loadPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_showReceivedDrawings = prefs.getBool(_showReceivedDrawingsKey) ?? true;
|
||||
_showSarMarkers = prefs.getBool(_showSarMarkersKey) ?? true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _savePreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_showReceivedDrawingsKey, _showReceivedDrawings);
|
||||
await prefs.setBool(_showSarMarkersKey, _showSarMarkers);
|
||||
}
|
||||
|
||||
/// Start drawing a line
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Message delivery tracking helper
|
||||
@@ -30,6 +31,9 @@ class MessageDeliveryTracker {
|
||||
/// Map of ACK tag to timestamp for timeout cleanup
|
||||
final Map<int, DateTime> _ackTagTimestamps = {};
|
||||
|
||||
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
|
||||
Completer<void>? _slotFreedCompleter;
|
||||
|
||||
/// Track a pending message ID before sending
|
||||
///
|
||||
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
||||
@@ -98,6 +102,11 @@ class MessageDeliveryTracker {
|
||||
return _ackTagToMessageId[ackCode];
|
||||
}
|
||||
|
||||
/// Returns true once a message has been matched to a concrete ACK tag.
|
||||
bool hasAckForMessage(String messageId) {
|
||||
return _messageIdToAckTag.containsKey(messageId);
|
||||
}
|
||||
|
||||
/// Remove ACK tag mapping after delivery confirmed or timeout
|
||||
///
|
||||
/// Cleans up both forward and reverse mappings.
|
||||
@@ -107,6 +116,7 @@ class MessageDeliveryTracker {
|
||||
_messageIdToAckTag.remove(messageId);
|
||||
}
|
||||
_ackTagTimestamps.remove(ackCode);
|
||||
_notifySlotFreed();
|
||||
}
|
||||
|
||||
/// Remove ACK tag mapping by message ID
|
||||
@@ -117,6 +127,7 @@ class MessageDeliveryTracker {
|
||||
if (ackTag != null) {
|
||||
_ackTagToMessageId.remove(ackTag);
|
||||
_ackTagTimestamps.remove(ackTag);
|
||||
_notifySlotFreed();
|
||||
}
|
||||
_pendingMessageIds.remove(messageId);
|
||||
final emptyKeys = <String>[];
|
||||
@@ -161,6 +172,7 @@ class MessageDeliveryTracker {
|
||||
_ackTagToMessageId.clear();
|
||||
_messageIdToAckTag.clear();
|
||||
_ackTagTimestamps.clear();
|
||||
_notifySlotFreed();
|
||||
}
|
||||
|
||||
/// Get count of pending ACK tags
|
||||
@@ -174,6 +186,27 @@ class MessageDeliveryTracker {
|
||||
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
||||
bool get shouldRateLimit => pendingCount >= 7;
|
||||
|
||||
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
|
||||
///
|
||||
/// Returns immediately if not at the rate limit.
|
||||
Future<void> waitForSlot({
|
||||
Duration timeout = const Duration(milliseconds: 500),
|
||||
}) async {
|
||||
if (!shouldRateLimit) return;
|
||||
_slotFreedCompleter ??= Completer<void>();
|
||||
await _slotFreedCompleter!.future.timeout(
|
||||
timeout,
|
||||
onTimeout: () {},
|
||||
);
|
||||
}
|
||||
|
||||
void _notifySlotFreed() {
|
||||
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
|
||||
_slotFreedCompleter!.complete();
|
||||
}
|
||||
_slotFreedCompleter = null;
|
||||
}
|
||||
|
||||
/// Get oldest pending ACK timestamp (for debugging)
|
||||
DateTime? get oldestPendingTimestamp {
|
||||
if (_ackTagTimestamps.isEmpty) return null;
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import '../../models/message.dart';
|
||||
import '../../models/contact.dart';
|
||||
|
||||
/// Manages message retry state and logic
|
||||
///
|
||||
/// This helper class centralizes retry logic for direct messages, implementing
|
||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
||||
/// with learned routing paths.
|
||||
/// This helper class centralizes retry logic for direct messages.
|
||||
///
|
||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||
/// - Firmware calculates timeout based on path length and airtime
|
||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||
/// - Flood mode: ~10-30 seconds for multi-hop
|
||||
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
|
||||
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
|
||||
/// - Firmware does NOT automatically retry - app must implement
|
||||
class MessageRetryManager {
|
||||
// Track retry state for each message ID
|
||||
@@ -22,10 +19,10 @@ class MessageRetryManager {
|
||||
final Map<String, DateTime> _lastRetryTimes = {};
|
||||
final Map<String, int> _pathFailureStreaks = {};
|
||||
|
||||
// Progressive timeout values in milliseconds
|
||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
||||
static const int maxRetryAttempts = 4;
|
||||
|
||||
// Retry backoff values in milliseconds.
|
||||
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
|
||||
static const int _defaultLoRaSf = 10;
|
||||
static const int _defaultLoRaCr = 5;
|
||||
static const int _defaultLoRaBwHz = 250000;
|
||||
@@ -33,13 +30,12 @@ class MessageRetryManager {
|
||||
static const int _defaultLoRaCrcEnabled = 1;
|
||||
static const int _defaultLoRaExplicitHeader = 1;
|
||||
|
||||
/// Get timeout for a specific retry attempt (0-2)
|
||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
||||
int getTimeoutForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
||||
return _timeouts.last; // Default to last timeout if out of range
|
||||
/// Get backoff delay for the next retry attempt.
|
||||
int getDelayForAttempt(int attempt) {
|
||||
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||
return _retryDelays.last;
|
||||
}
|
||||
return _timeouts[attempt];
|
||||
return _retryDelays[attempt];
|
||||
}
|
||||
|
||||
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
||||
@@ -55,8 +51,8 @@ class MessageRetryManager {
|
||||
|
||||
final payloadBytes = utf8.encode(text).length;
|
||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||
final hopCount = contact?.hasPath == true
|
||||
? math.max(contact!.outPathLen, 0)
|
||||
final hopCount = contact?.routeHasPath == true
|
||||
? contact!.routeHopCount
|
||||
: -1;
|
||||
|
||||
if (hopCount < 0) {
|
||||
@@ -66,43 +62,8 @@ class MessageRetryManager {
|
||||
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||
}
|
||||
|
||||
/// Check if a message is eligible for retry
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - The message has retryAttempt < 3
|
||||
/// - The contact has a learned path (contact.hasPath == true)
|
||||
/// - The message hasn't used flood fallback yet
|
||||
///
|
||||
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
|
||||
bool canRetry(Message message, Contact contact) {
|
||||
// Never retry if already tried flood mode
|
||||
if (message.usedFloodFallback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Never retry beyond 3 attempts
|
||||
if (message.retryAttempt >= 3) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only retry if contact has a learned path
|
||||
// If no path, the device uses flood mode automatically - retrying won't help
|
||||
return contact.hasPath;
|
||||
}
|
||||
|
||||
/// Check if should fall back to flood mode
|
||||
///
|
||||
/// Returns true if:
|
||||
/// - Message has exhausted all 3 retry attempts with direct mode
|
||||
/// - Contact HAS a learned path (so direct mode was used)
|
||||
/// - Hasn't already used flood fallback
|
||||
///
|
||||
/// IMPORTANT: Only contacts WITH paths need flood fallback.
|
||||
/// Contacts without paths already use flood mode automatically.
|
||||
bool shouldUseFloodFallback(Message message, Contact contact) {
|
||||
return message.retryAttempt >= 3 &&
|
||||
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths
|
||||
!message.usedFloodFallback;
|
||||
return message.retryAttempt < maxRetryAttempts;
|
||||
}
|
||||
|
||||
/// Track a retry attempt for a message
|
||||
|
||||
@@ -46,8 +46,8 @@ class PingTracker {
|
||||
/// Mark a ping as successful (response received)
|
||||
/// Should be called when telemetry response arrives
|
||||
void markPingSuccessful(Uint8List publicKey) {
|
||||
final String keyHex = _publicKeyToHex(publicKey);
|
||||
final request = _pendingPings.remove(keyHex);
|
||||
final requestKey = _findMatchingPendingPingKey(publicKey);
|
||||
final request = requestKey != null ? _pendingPings.remove(requestKey) : null;
|
||||
|
||||
if (request != null) {
|
||||
request.cancel();
|
||||
@@ -81,6 +81,23 @@ class PingTracker {
|
||||
String _publicKeyToHex(Uint8List publicKey) {
|
||||
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
|
||||
}
|
||||
|
||||
String? _findMatchingPendingPingKey(Uint8List responseKey) {
|
||||
final responseHex = _publicKeyToHex(responseKey);
|
||||
|
||||
if (_pendingPings.containsKey(responseHex)) {
|
||||
return responseHex;
|
||||
}
|
||||
|
||||
for (final entry in _pendingPings.entries) {
|
||||
final pendingHex = entry.key;
|
||||
if (pendingHex.startsWith(responseHex) || responseHex.startsWith(pendingHex)) {
|
||||
return pendingHex;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal class to track a single ping request
|
||||
|
||||
@@ -9,13 +9,6 @@ typedef RawPacketSender =
|
||||
required Uint8List payload,
|
||||
});
|
||||
|
||||
typedef FragmentAckWaiter =
|
||||
Future<bool> Function({
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout,
|
||||
});
|
||||
|
||||
Future<bool> serveCachedSessionFragments<T>({
|
||||
required String providerLabel,
|
||||
required String sessionId,
|
||||
@@ -25,9 +18,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
required int Function(T fragment) indexOf,
|
||||
required Uint8List Function(T fragment) encodeBinary,
|
||||
required RawPacketSender? sendRawPacket,
|
||||
FragmentAckWaiter? waitForFragmentAck,
|
||||
Set<int>? requestedIndices,
|
||||
Duration ackTimeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
if (fragments.isEmpty) {
|
||||
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
||||
@@ -37,13 +28,13 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
|
||||
return false;
|
||||
}
|
||||
if (requester.outPathLen < 0) {
|
||||
if (!requester.routeHasPath) {
|
||||
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
|
||||
return false;
|
||||
}
|
||||
if (requester.outPathLen > maxDirectPayloadHops) {
|
||||
if (requester.routeHopCount > maxDirectPayloadHops) {
|
||||
debugPrint(
|
||||
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
|
||||
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.routeHopCount} hops (max $maxDirectPayloadHops)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -65,24 +56,12 @@ Future<bool> serveCachedSessionFragments<T>({
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
final ackFuture = waitForFragmentAck?.call(
|
||||
sessionId: sessionId,
|
||||
index: index,
|
||||
timeout: ackTimeout,
|
||||
);
|
||||
await sendRawPacket(
|
||||
contactPath: requester.outPath,
|
||||
contactPathLen: requester.outPathLen,
|
||||
contactPathLen: requester.routeEncodedPathLen,
|
||||
payload: encodeBinary(fragment),
|
||||
);
|
||||
servedCount++;
|
||||
if (ackFuture != null) {
|
||||
final acked = await ackFuture;
|
||||
if (!acked) {
|
||||
debugPrint('⚠️ [$providerLabel] ACK timeout for $sessionId#$index');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint(
|
||||
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
|
||||
|
||||
@@ -1,38 +1,57 @@
|
||||
import '../../models/message.dart';
|
||||
import '../../utils/image_message_parser.dart';
|
||||
import '../../utils/voice_message_parser.dart';
|
||||
|
||||
class RestoredSessionMetadata {
|
||||
final Map<String, String> voiceSenderKeyBySession;
|
||||
final Map<String, String> imageSenderKeyBySession;
|
||||
final Map<String, ImageEnvelope> imageEnvelopeBySession;
|
||||
|
||||
const RestoredSessionMetadata({
|
||||
required this.voiceSenderKeyBySession,
|
||||
required this.imageSenderKeyBySession,
|
||||
required this.imageEnvelopeBySession,
|
||||
});
|
||||
}
|
||||
|
||||
RestoredSessionMetadata restoreSessionMetadataFromMessages(
|
||||
Iterable<String> messageTexts,
|
||||
Iterable<Message> messages,
|
||||
) {
|
||||
final voiceSenderKeyBySession = <String, String>{};
|
||||
final imageSenderKeyBySession = <String, String>{};
|
||||
final imageEnvelopeBySession = <String, ImageEnvelope>{};
|
||||
|
||||
for (final text in messageTexts) {
|
||||
for (final message in messages) {
|
||||
final text = message.text;
|
||||
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
|
||||
if (voiceEnvelope != null) {
|
||||
voiceSenderKeyBySession[voiceEnvelope.sessionId] = voiceEnvelope
|
||||
.senderKey6
|
||||
.toLowerCase();
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
voiceSenderKeyBySession[voiceEnvelope.sessionId] = senderPrefix
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
final imageEnvelope = ImageEnvelope.tryParse(text);
|
||||
if (imageEnvelope != null) {
|
||||
imageEnvelopeBySession[imageEnvelope.sessionId] = imageEnvelope;
|
||||
final senderPrefix = message.senderPublicKeyPrefix;
|
||||
if (senderPrefix != null && senderPrefix.length >= 6) {
|
||||
imageSenderKeyBySession[imageEnvelope.sessionId] = senderPrefix
|
||||
.sublist(0, 6)
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0'))
|
||||
.join()
|
||||
.toLowerCase();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return RestoredSessionMetadata(
|
||||
voiceSenderKeyBySession: voiceSenderKeyBySession,
|
||||
imageSenderKeyBySession: imageSenderKeyBySession,
|
||||
imageEnvelopeBySession: imageEnvelopeBySession,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,12 +70,6 @@ class ImageProvider with ChangeNotifier {
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
Future<bool> Function({
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout,
|
||||
})?
|
||||
waitForFragmentAckCallback;
|
||||
|
||||
ImageProvider() {
|
||||
_restore();
|
||||
@@ -102,11 +96,28 @@ class ImageProvider with ChangeNotifier {
|
||||
return missing;
|
||||
}
|
||||
|
||||
List<int> availableFragmentIndices(String sessionId) {
|
||||
final outgoing = _outgoing[sessionId];
|
||||
if (outgoing != null) {
|
||||
return outgoing.fragments.map((fragment) => fragment.index).toList()
|
||||
..sort();
|
||||
}
|
||||
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final indices = <int>[];
|
||||
for (var i = 0; i < session.fragments.length; i++) {
|
||||
if (session.fragments[i] != null) {
|
||||
indices.add(i);
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
// ── Incoming fragment reception ──────────────────────────────────────────
|
||||
|
||||
/// Add a received [fragment]. Creates the session on first fragment using
|
||||
/// metadata from the fragment itself (requires envelope to have been
|
||||
/// announced first; if not, defaults width/height to 0 — corrected on save).
|
||||
/// Add a received [fragment]. New compact fragments rely on prior envelope
|
||||
/// metadata for total/format, while legacy fragments can still self-describe.
|
||||
///
|
||||
/// Returns true when the session just became complete.
|
||||
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
|
||||
@@ -116,16 +127,20 @@ class ImageProvider with ChangeNotifier {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_sessions.putIfAbsent(
|
||||
fragment.sessionId,
|
||||
() => ImageSession(
|
||||
_sessions.putIfAbsent(fragment.sessionId, () {
|
||||
if (fragment.total < 1) {
|
||||
throw StateError(
|
||||
'Image envelope missing for compact fragment ${fragment.sessionId}',
|
||||
);
|
||||
}
|
||||
return ImageSession(
|
||||
sessionId: fragment.sessionId,
|
||||
format: fragment.format,
|
||||
total: fragment.total,
|
||||
width: width,
|
||||
height: height,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
final session = _sessions[fragment.sessionId]!;
|
||||
if (fragment.index < session.total) {
|
||||
@@ -250,21 +265,26 @@ class ImageProvider with ChangeNotifier {
|
||||
required Contact requester,
|
||||
Set<int>? requestedIndices,
|
||||
}) async {
|
||||
final cached = _outgoing[sessionId];
|
||||
if (cached == null) {
|
||||
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId');
|
||||
final outgoing = _outgoing[sessionId];
|
||||
final fragments = outgoing != null
|
||||
? List<ImagePacket>.from(outgoing.fragments)
|
||||
: _sessions[sessionId]?.fragments.whereType<ImagePacket>().toList() ??
|
||||
const <ImagePacket>[];
|
||||
if (fragments.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [ImageProvider] No cached or received session for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return serveCachedSessionFragments<ImagePacket>(
|
||||
providerLabel: 'ImageProvider',
|
||||
sessionId: sessionId,
|
||||
requester: requester,
|
||||
fragments: cached.fragments,
|
||||
fragments: fragments,
|
||||
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||
indexOf: (fragment) => fragment.index,
|
||||
encodeBinary: (fragment) => fragment.encodeBinary(),
|
||||
sendRawPacket: sendRawPacketCallback,
|
||||
waitForFragmentAck: waitForFragmentAckCallback,
|
||||
requestedIndices: requestedIndices,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
@@ -6,6 +8,10 @@ import '../models/location_trail.dart';
|
||||
import '../models/map_drawing.dart';
|
||||
|
||||
class MapProvider with ChangeNotifier {
|
||||
MapProvider() {
|
||||
unawaited(_loadInitialState());
|
||||
}
|
||||
|
||||
LatLng? _targetLocation;
|
||||
double? _targetZoom;
|
||||
bool _shouldAnimate = false;
|
||||
@@ -33,6 +39,7 @@ class MapProvider with ChangeNotifier {
|
||||
|
||||
// Contact trail toggles
|
||||
bool _showAllContactTrails = true; // Default to showing all contact trails
|
||||
bool _hideRepeatersOnMap = false;
|
||||
|
||||
// Imported trail (from GPX)
|
||||
LocationTrail? _importedTrail;
|
||||
@@ -67,6 +74,7 @@ class MapProvider with ChangeNotifier {
|
||||
|
||||
// Contact trail getters
|
||||
bool get showAllContactTrails => _showAllContactTrails;
|
||||
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
|
||||
|
||||
// Imported trail getters
|
||||
LocationTrail? get importedTrail => _importedTrail;
|
||||
@@ -350,6 +358,11 @@ class MapProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialState() async {
|
||||
await Future.wait([loadOverlayState(), loadTrailSettings()]);
|
||||
await loadRepeaterVisibilitySettings();
|
||||
}
|
||||
|
||||
/// Save overlay state to SharedPreferences
|
||||
Future<void> _saveOverlayState() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -386,6 +399,20 @@ class MapProvider with ChangeNotifier {
|
||||
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
|
||||
}
|
||||
|
||||
Future<void> setHideRepeatersOnMap(bool hide) async {
|
||||
if (_hideRepeatersOnMap == hide) return;
|
||||
_hideRepeatersOnMap = hide;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('map_hide_repeaters', _hideRepeatersOnMap);
|
||||
}
|
||||
|
||||
Future<void> loadRepeaterVisibilitySettings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_hideRepeatersOnMap = prefs.getBool('map_hide_repeaters') ?? false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Set imported trail (from GPX import)
|
||||
void setImportedTrail(LocationTrail trail) {
|
||||
_importedTrail = trail;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -71,12 +72,6 @@ class VoiceProvider with ChangeNotifier {
|
||||
required Uint8List payload,
|
||||
})?
|
||||
sendRawPacketCallback;
|
||||
Future<bool> Function({
|
||||
required String sessionId,
|
||||
required int index,
|
||||
Duration timeout,
|
||||
})?
|
||||
waitForFragmentAckCallback;
|
||||
|
||||
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
|
||||
|
||||
@@ -131,6 +126,23 @@ class VoiceProvider with ChangeNotifier {
|
||||
return missing;
|
||||
}
|
||||
|
||||
List<int> availablePacketIndices(String sessionId) {
|
||||
final outgoing = _outgoingSessions[sessionId];
|
||||
if (outgoing != null) {
|
||||
return outgoing.packets.map((packet) => packet.index).toList()..sort();
|
||||
}
|
||||
|
||||
final session = _sessions[sessionId];
|
||||
if (session == null) return const [];
|
||||
final indices = <int>[];
|
||||
for (var i = 0; i < session.packets.length; i++) {
|
||||
if (session.packets[i] != null) {
|
||||
indices.add(i);
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
// ── Packet reception ─────────────────────────────────────────────────────
|
||||
|
||||
/// Add an incoming [packet] to its session. Creates the session on first packet.
|
||||
@@ -142,14 +154,18 @@ class VoiceProvider with ChangeNotifier {
|
||||
);
|
||||
return false;
|
||||
}
|
||||
_sessions.putIfAbsent(
|
||||
packet.sessionId,
|
||||
() => VoiceSession(
|
||||
_sessions.putIfAbsent(packet.sessionId, () {
|
||||
if (packet.total < 1) {
|
||||
throw StateError(
|
||||
'Voice envelope missing for compact packet ${packet.sessionId}',
|
||||
);
|
||||
}
|
||||
return VoiceSession(
|
||||
sessionId: packet.sessionId,
|
||||
mode: packet.mode,
|
||||
total: packet.total,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
|
||||
final session = _sessions[packet.sessionId]!;
|
||||
if (packet.index < session.total) {
|
||||
@@ -185,6 +201,47 @@ class VoiceProvider with ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
void registerEnvelope(VoiceEnvelope envelope) {
|
||||
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
|
||||
return;
|
||||
}
|
||||
final existing = _sessions[envelope.sessionId];
|
||||
if (existing == null) {
|
||||
_sessions[envelope.sessionId] = VoiceSession(
|
||||
sessionId: envelope.sessionId,
|
||||
mode: envelope.mode,
|
||||
total: envelope.total,
|
||||
);
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final needsMerge =
|
||||
existing.total != envelope.total || existing.mode != envelope.mode;
|
||||
if (!needsMerge) {
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final merged = VoiceSession(
|
||||
sessionId: envelope.sessionId,
|
||||
mode: envelope.mode,
|
||||
total: envelope.total,
|
||||
);
|
||||
merged.firstPacketAt = existing.firstPacketAt;
|
||||
merged.lastPacketAt = existing.lastPacketAt;
|
||||
for (final packet in existing.packets) {
|
||||
if (packet == null) continue;
|
||||
if (packet.index < merged.total) {
|
||||
merged.packets[packet.index] = packet;
|
||||
}
|
||||
}
|
||||
_sessions[envelope.sessionId] = merged;
|
||||
_persistVoiceData();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Cache encoded packets for deferred voice serving.
|
||||
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
|
||||
if (packets.isEmpty) return;
|
||||
@@ -201,10 +258,14 @@ class VoiceProvider with ChangeNotifier {
|
||||
required Contact requester,
|
||||
Set<int>? requestedIndices,
|
||||
}) async {
|
||||
final cached = _outgoingSessions[sessionId];
|
||||
if (cached == null) {
|
||||
final outgoing = _outgoingSessions[sessionId];
|
||||
final packets = outgoing != null
|
||||
? List<VoicePacket>.from(outgoing.packets)
|
||||
: _sessions[sessionId]?.packets.whereType<VoicePacket>().toList() ??
|
||||
const <VoicePacket>[];
|
||||
if (packets.isEmpty) {
|
||||
debugPrint(
|
||||
'⚠️ [VoiceProvider] No cached outgoing session for $sessionId',
|
||||
'⚠️ [VoiceProvider] No cached or received session for $sessionId',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
@@ -212,12 +273,11 @@ class VoiceProvider with ChangeNotifier {
|
||||
providerLabel: 'VoiceProvider',
|
||||
sessionId: sessionId,
|
||||
requester: requester,
|
||||
fragments: cached.packets,
|
||||
fragments: packets,
|
||||
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||
indexOf: (packet) => packet.index,
|
||||
encodeBinary: (packet) => packet.encodeBinary(),
|
||||
sendRawPacket: sendRawPacketCallback,
|
||||
waitForFragmentAck: waitForFragmentAckCallback,
|
||||
requestedIndices: requestedIndices,
|
||||
);
|
||||
}
|
||||
@@ -240,11 +300,15 @@ class VoiceProvider with ChangeNotifier {
|
||||
);
|
||||
|
||||
try {
|
||||
final pcm = await _codec.decodePackets(session.packets, session.mode);
|
||||
final decodedPcm = await _codec.decodePackets(
|
||||
session.packets,
|
||||
session.mode,
|
||||
);
|
||||
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
|
||||
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
||||
_playingSessionId = sessionId;
|
||||
notifyListeners();
|
||||
await _player.play(pcm);
|
||||
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
|
||||
} catch (e, st) {
|
||||
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
||||
if (_playingSessionId == sessionId) {
|
||||
@@ -260,6 +324,10 @@ class VoiceProvider with ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
|
||||
return pcm;
|
||||
}
|
||||
|
||||
Future<void> clearStoredVoiceData() async {
|
||||
_sessions.clear();
|
||||
_outgoingSessions.clear();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
660
lib/screens/discovery_screen.dart
Normal file
660
lib/screens/discovery_screen.dart
Normal file
@@ -0,0 +1,660 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
|
||||
|
||||
enum _DiscoveryMenuAction { repeaters, sensors }
|
||||
|
||||
class DiscoveryScreen extends StatefulWidget {
|
||||
const DiscoveryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
|
||||
}
|
||||
|
||||
class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
static const int _repeaterAdvertType = 2;
|
||||
static const int _sensorAdvertType = 4;
|
||||
final Set<String> _resolvingAdvertKeys = <String>{};
|
||||
final Set<int> _runningDiscoveryTypes = <int>{};
|
||||
bool _isResolvingAll = false;
|
||||
late final Future<List<MeshMapNode>> _cachedNodesFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
|
||||
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
|
||||
switch (action) {
|
||||
case _DiscoveryMenuAction.repeaters:
|
||||
await _discoverNodeType(_repeaterAdvertType);
|
||||
break;
|
||||
case _DiscoveryMenuAction.sensors:
|
||||
await _discoverNodeType(_sensorAdvertType);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearAllDiscoveries() async {
|
||||
final pendingCount = context.read<ContactsProvider>().pendingAdverts.length;
|
||||
if (pendingCount == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Clear discoveries'),
|
||||
content: Text(
|
||||
'Remove all $pendingCount pending discover${pendingCount == 1 ? 'y' : 'ies'} from this device?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('Clear all'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.read<ContactsProvider>().clearPendingAdverts();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Cleared pending discoveries.')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _discoverNodeType(int advertType) async {
|
||||
if (_runningDiscoveryTypes.contains(advertType)) return;
|
||||
|
||||
setState(() {
|
||||
_runningDiscoveryTypes.add(advertType);
|
||||
});
|
||||
|
||||
try {
|
||||
await context.read<ConnectionProvider>().discoverNodeType(
|
||||
advertType: advertType,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final label = switch (advertType) {
|
||||
_repeaterAdvertType => 'Repeater discovery sent',
|
||||
_sensorAdvertType => 'Sensor discovery sent',
|
||||
_ => 'Discovery sent',
|
||||
};
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(label)));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_runningDiscoveryTypes.remove(advertType);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resolveAdvert(PendingAdvert advert) async {
|
||||
final keyHex = advert.publicKeyHex;
|
||||
if (_resolvingAdvertKeys.contains(keyHex)) return;
|
||||
|
||||
setState(() {
|
||||
_resolvingAdvertKeys.add(keyHex);
|
||||
});
|
||||
|
||||
try {
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
|
||||
await connectionProvider.getContact(advert.publicKey);
|
||||
if (connectionProvider.error == 'Not found') {
|
||||
connectionProvider.clearError();
|
||||
final fallbackContact = _contactFromPendingAdvert(advert);
|
||||
await connectionProvider.addOrUpdateContact(fallbackContact);
|
||||
contactsProvider.addOrUpdateContact(
|
||||
fallbackContact,
|
||||
devicePublicKey: connectionProvider.deviceInfo.publicKey,
|
||||
);
|
||||
}
|
||||
if ((advert.typeValue ?? 0) == _sensorAdvertType) {
|
||||
await connectionProvider.requestTelemetry(advert.publicKey);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_resolvingAdvertKeys.remove(keyHex);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resolveAll(List<PendingAdvert> adverts) async {
|
||||
if (_isResolvingAll || adverts.isEmpty) return;
|
||||
|
||||
setState(() {
|
||||
_isResolvingAll = true;
|
||||
});
|
||||
|
||||
try {
|
||||
for (final advert in adverts) {
|
||||
if (!mounted) break;
|
||||
await _resolveAdvert(advert);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isResolvingAll = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _formatRelativeTime(BuildContext context, DateTime when) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final diff = DateTime.now().difference(when);
|
||||
if (diff.inMinutes < 1) return l10n.justNow;
|
||||
if (diff.inMinutes < 60) return l10n.minutesAgo(diff.inMinutes);
|
||||
if (diff.inHours < 24) return l10n.hoursAgo(diff.inHours);
|
||||
return l10n.daysAgo(diff.inDays);
|
||||
}
|
||||
|
||||
Contact _contactFromPendingAdvert(PendingAdvert advert) {
|
||||
final lastAdvert =
|
||||
advert.lastAdvert ?? (advert.receivedAt.millisecondsSinceEpoch ~/ 1000);
|
||||
final advName = advert.advName?.trim().isNotEmpty == true
|
||||
? advert.advName!.trim()
|
||||
: advert.shortDisplayKey;
|
||||
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(advert.publicKey),
|
||||
type: ContactType.fromValue(advert.typeValue ?? 0),
|
||||
flags: advert.flags ?? 0,
|
||||
outPathLen: advert.signedEncodedPathLen ?? -1,
|
||||
outPath: advert.paddedPathBytes == null
|
||||
? Uint8List(64)
|
||||
: Uint8List.fromList(advert.paddedPathBytes!),
|
||||
advName: advName,
|
||||
lastAdvert: lastAdvert,
|
||||
advLat: advert.advLat ?? 0,
|
||||
advLon: advert.advLon ?? 0,
|
||||
lastMod: lastAdvert,
|
||||
);
|
||||
}
|
||||
|
||||
String _displayNameForAdvert(
|
||||
PendingAdvert advert,
|
||||
ContactsProvider contactsProvider,
|
||||
List<MeshMapNode> cachedNodes,
|
||||
) {
|
||||
final advertisedName = advert.advName?.trim();
|
||||
if (advertisedName != null && advertisedName.isNotEmpty) {
|
||||
return advertisedName;
|
||||
}
|
||||
|
||||
Contact? existingMatch;
|
||||
for (final contact in contactsProvider.contacts) {
|
||||
if (contact.publicKeyHex == advert.publicKeyHex) {
|
||||
existingMatch = contact;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (existingMatch != null && existingMatch.displayName.trim().isNotEmpty) {
|
||||
return existingMatch.displayName;
|
||||
}
|
||||
|
||||
MeshMapNode? cachedMatch;
|
||||
for (final node in cachedNodes) {
|
||||
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
|
||||
cachedMatch = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cachedMatch != null && cachedMatch.name.trim().isNotEmpty) {
|
||||
return cachedMatch.name;
|
||||
}
|
||||
|
||||
return advert.shortDisplayKey;
|
||||
}
|
||||
|
||||
IconData _iconForAdvert(PendingAdvert advert) {
|
||||
return switch (advert.typeValue) {
|
||||
_repeaterAdvertType => Icons.router_outlined,
|
||||
_sensorAdvertType => Icons.sensors_outlined,
|
||||
_ => Icons.campaign_outlined,
|
||||
};
|
||||
}
|
||||
|
||||
String? _typeLabelForAdvert(PendingAdvert advert) {
|
||||
return switch (advert.typeValue) {
|
||||
_repeaterAdvertType => 'Repeater',
|
||||
_sensorAdvertType => 'Sensor',
|
||||
3 => 'Room',
|
||||
1 => 'Chat',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
String? _resolvedTypeLabelForAdvert(
|
||||
PendingAdvert advert,
|
||||
List<MeshMapNode> cachedNodes,
|
||||
) {
|
||||
final directType = _typeLabelForAdvert(advert);
|
||||
if (directType != null) {
|
||||
return directType;
|
||||
}
|
||||
|
||||
for (final node in cachedNodes) {
|
||||
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
|
||||
return switch (node.type) {
|
||||
1 => 'Repeater',
|
||||
4 => 'Sensor',
|
||||
3 => 'Room',
|
||||
2 => 'Chat',
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _buildAdvertTitle(
|
||||
BuildContext context, {
|
||||
required String displayName,
|
||||
String? subtitle,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
if (subtitle != null && subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Discovery'),
|
||||
actions: [
|
||||
Consumer<ConnectionProvider>(
|
||||
builder: (context, connectionProvider, child) {
|
||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||
final repeatersBusy = _runningDiscoveryTypes.contains(
|
||||
_repeaterAdvertType,
|
||||
);
|
||||
final sensorsBusy = _runningDiscoveryTypes.contains(
|
||||
_sensorAdvertType,
|
||||
);
|
||||
|
||||
return PopupMenuButton<_DiscoveryMenuAction>(
|
||||
tooltip: 'Discovery tools',
|
||||
onSelected: _handleMenuAction,
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<_DiscoveryMenuAction>(
|
||||
value: _DiscoveryMenuAction.repeaters,
|
||||
enabled: isConnected && !repeatersBusy,
|
||||
child: Row(
|
||||
children: [
|
||||
repeatersBusy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.router_outlined),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Discover repeaters'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<_DiscoveryMenuAction>(
|
||||
value: _DiscoveryMenuAction.sensors,
|
||||
enabled: isConnected && !sensorsBusy,
|
||||
child: Row(
|
||||
children: [
|
||||
sensorsBusy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.sensors_outlined),
|
||||
const SizedBox(width: 12),
|
||||
const Text('Discover sensors'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<List<MeshMapNode>>(
|
||||
future: _cachedNodesFuture,
|
||||
builder: (context, nodesSnapshot) =>
|
||||
Consumer2<ContactsProvider, ConnectionProvider>(
|
||||
builder: (context, contactsProvider, connectionProvider, child) {
|
||||
final pendingAdverts = contactsProvider.pendingAdverts;
|
||||
final isConnected = connectionProvider.deviceInfo.isConnected;
|
||||
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.person_search),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pending discoveries (${pendingAdverts.length})',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Resolve entries manually so they do not auto-populate contacts.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: isConnected &&
|
||||
pendingAdverts.isNotEmpty &&
|
||||
!_isResolvingAll
|
||||
? () => _resolveAll(pendingAdverts)
|
||||
: null,
|
||||
icon: _isResolvingAll
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.download_for_offline_outlined,
|
||||
),
|
||||
label: const Text('Resolve all'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: pendingAdverts.isNotEmpty
|
||||
? _clearAllDiscoveries
|
||||
: null,
|
||||
icon: const Icon(Icons.clear_all_rounded),
|
||||
label: const Text('Clear all'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (pendingAdverts.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_search_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context).disabledColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No pending discoveries',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Unknown adverts will appear here until you choose to resolve them.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...pendingAdverts.map((advert) {
|
||||
final isResolving = _resolvingAdvertKeys.contains(
|
||||
advert.publicKeyHex,
|
||||
);
|
||||
final displayName = _displayNameForAdvert(
|
||||
advert,
|
||||
contactsProvider,
|
||||
cachedNodes,
|
||||
);
|
||||
final typeLabel = _resolvedTypeLabelForAdvert(
|
||||
advert,
|
||||
cachedNodes,
|
||||
);
|
||||
final downMetric = SignalMetric.fromValues(
|
||||
rssiDbm: advert.rxRssiDbm,
|
||||
snrDb: advert.rxSnr,
|
||||
);
|
||||
final upMetric = SignalMetric.fromValues(
|
||||
rssiDbm: advert.repeaterLastRssi,
|
||||
snrDb: advert.repeaterLastSnr,
|
||||
);
|
||||
final detailLines = <String>[
|
||||
'${l10n.publicKey}: ${advert.shortDisplayKey}',
|
||||
];
|
||||
final summaryParts = <String>[];
|
||||
final battery = advert.repeaterBatteryPercent;
|
||||
if (battery != null) {
|
||||
summaryParts.add('Battery ${battery.round()}%');
|
||||
}
|
||||
if (advert.repeaterQueueLen != null) {
|
||||
summaryParts.add('Queue ${advert.repeaterQueueLen}');
|
||||
}
|
||||
if (summaryParts.isNotEmpty) {
|
||||
detailLines.add(summaryParts.join(' • '));
|
||||
}
|
||||
detailLines.add(
|
||||
'${l10n.lastSeen}: ${_formatRelativeTime(context, advert.receivedAt)}',
|
||||
);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
child: Icon(_iconForAdvert(advert)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildAdvertTitle(
|
||||
context,
|
||||
displayName: displayName,
|
||||
subtitle: typeLabel,
|
||||
),
|
||||
),
|
||||
if (downMetric != null || upMetric != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
_buildSignalSummary(
|
||||
context,
|
||||
downMetric: downMetric,
|
||||
upMetric: upMetric,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
isResolving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(
|
||||
Icons.person_add_alt_1,
|
||||
),
|
||||
tooltip: 'Resolve contact',
|
||||
onPressed: isConnected
|
||||
? () => _resolveAdvert(advert)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
detailLines.join('\n'),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSignalSummary(
|
||||
BuildContext context, {
|
||||
SignalMetric? downMetric,
|
||||
SignalMetric? upMetric,
|
||||
}) {
|
||||
if (downMetric == null && upMetric == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (downMetric != null)
|
||||
_buildDirectionalSignal(
|
||||
context,
|
||||
icon: Icons.south_west_rounded,
|
||||
metric: downMetric,
|
||||
),
|
||||
if (downMetric != null && upMetric != null) const SizedBox(height: 6),
|
||||
if (upMetric != null)
|
||||
_buildDirectionalSignal(
|
||||
context,
|
||||
icon: Icons.north_east_rounded,
|
||||
metric: upMetric,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDirectionalSignal(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required SignalMetric metric,
|
||||
}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 11,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
_buildMiniSignalBars(context, metric),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
metric.valueLabel,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMiniSignalBars(BuildContext context, SignalMetric metric) {
|
||||
final inactive = Theme.of(context).colorScheme.outlineVariant;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (var index = 0; index < 3; index++) ...[
|
||||
if (index > 0) const SizedBox(width: 2),
|
||||
Container(
|
||||
width: 3,
|
||||
height: 6.0 + (index * 4),
|
||||
decoration: BoxDecoration(
|
||||
color: index < metric.activeBars ? metric.color : inactive,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1134
lib/screens/live_traffic_screen.dart
Normal file
1134
lib/screens/live_traffic_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'dart:io';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:meshcore_client/meshcore_client.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
import '../services/route_hash_preferences.dart';
|
||||
import '../utils/log_rx_route_decoder.dart';
|
||||
|
||||
class PacketLogScreen extends StatefulWidget {
|
||||
final MeshCoreBleService bleService;
|
||||
@@ -580,6 +586,10 @@ class _PacketLogCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isRx) ...[
|
||||
const SizedBox(height: 12),
|
||||
_DecodedRouteSection(log: log),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -723,6 +733,242 @@ 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();
|
||||
return _RouteSection(route: decodedRoute, path: resolvedPath);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ResolvedNodeHash _resolveHashWithFallback(
|
||||
String hashHex, {
|
||||
required List<Contact> contacts,
|
||||
required List<MeshMapNode> cachedNodes,
|
||||
required Uint8List? ownPublicKey,
|
||||
required String? ownName,
|
||||
}) {
|
||||
final localResolved = LogRxRouteDecoder.resolveHash(
|
||||
hashHex,
|
||||
contacts: contacts,
|
||||
ownPublicKey: ownPublicKey,
|
||||
ownName: ownName,
|
||||
);
|
||||
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
|
||||
return localResolved;
|
||||
}
|
||||
|
||||
final normalizedHashHex = hashHex.toLowerCase();
|
||||
final matches = cachedNodes
|
||||
.where((node) => node.publicKey.startsWith(normalizedHashHex))
|
||||
.toList();
|
||||
if (matches.isEmpty) {
|
||||
return localResolved;
|
||||
}
|
||||
|
||||
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||
return ResolvedNodeHash(
|
||||
hashHex: normalizedHashHex,
|
||||
label: matches.first.name,
|
||||
isOwnNode: false,
|
||||
isUniqueMatch: matches.length == 1,
|
||||
matchCount: matches.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteSection extends StatelessWidget {
|
||||
final DecodedLogRxRoute route;
|
||||
final List<ResolvedNodeHash> path;
|
||||
|
||||
const _RouteSection({required this.route, required this.path});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(Icons.alt_route, size: 16),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Mesh Route',
|
||||
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
_FactCard(
|
||||
icon: Icons.route,
|
||||
label: 'Payload',
|
||||
value: _payloadTypeLabel(route.payloadType),
|
||||
),
|
||||
_FactCard(
|
||||
icon: Icons.hub,
|
||||
label: 'Hops',
|
||||
value: '${route.hopCount}',
|
||||
),
|
||||
_FactCard(
|
||||
icon: Icons.tag,
|
||||
label: 'Hash size',
|
||||
value:
|
||||
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (path.isEmpty)
|
||||
Text(
|
||||
'Direct packet, no hop path attached.',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (var i = 0; i < path.length; i++) ...[
|
||||
_RouteHopChip(index: i + 1, node: path[i]),
|
||||
if (i < path.length - 1)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(Icons.arrow_right_alt, size: 16),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _payloadTypeLabel(int payloadType) {
|
||||
switch (payloadType) {
|
||||
case 0x00:
|
||||
return 'REQ';
|
||||
case 0x01:
|
||||
return 'RESP';
|
||||
case 0x02:
|
||||
return 'TXT';
|
||||
case 0x03:
|
||||
return 'ACK';
|
||||
case 0x04:
|
||||
return 'ADVERT';
|
||||
case 0x05:
|
||||
return 'GRP_TXT';
|
||||
case 0x06:
|
||||
return 'GRP_DATA';
|
||||
case 0x07:
|
||||
return 'ANON_REQ';
|
||||
case 0x08:
|
||||
return 'PATH';
|
||||
case 0x09:
|
||||
return 'TRACE';
|
||||
case 0x0A:
|
||||
return 'MULTIPART';
|
||||
case 0x0B:
|
||||
return 'CONTROL';
|
||||
default:
|
||||
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
static String _nodeLabel(ResolvedNodeHash node) {
|
||||
if (node.isOwnNode) {
|
||||
return '${node.label} (${node.hexLabel})';
|
||||
}
|
||||
if (node.matchCount == 0) {
|
||||
return node.hexLabel;
|
||||
}
|
||||
if (node.isUniqueMatch) {
|
||||
return '${node.label} (${node.hexLabel})';
|
||||
}
|
||||
return '${node.label} (${node.hexLabel}, ${node.matchCount} matches)';
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteHopChip extends StatelessWidget {
|
||||
final int index;
|
||||
final ResolvedNodeHash node;
|
||||
|
||||
const _RouteHopChip({required this.index, required this.node});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = node.isOwnNode
|
||||
? Colors.blue
|
||||
: node.isUniqueMatch
|
||||
? Colors.green
|
||||
: Colors.orange;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: color.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Text(
|
||||
'$index. ${_RouteSection._nodeLabel(node)}',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FactCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
775
lib/screens/repeaters_map_screen.dart
Normal file
775
lib/screens/repeaters_map_screen.dart
Normal file
@@ -0,0 +1,775 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../providers/contacts_provider.dart';
|
||||
import '../services/mesh_map_nodes_service.dart';
|
||||
|
||||
class RepeatersMapScreen extends StatefulWidget {
|
||||
const RepeatersMapScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RepeatersMapScreen> createState() => _RepeatersMapScreenState();
|
||||
}
|
||||
|
||||
class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
|
||||
static const LatLng _fallbackCenter = LatLng(46.0569, 14.5058);
|
||||
static const double _fallbackZoom = 7;
|
||||
static const double _myLocationZoom = 14;
|
||||
|
||||
final flutter_map.MapController _mapController = flutter_map.MapController();
|
||||
|
||||
List<MeshMapNode> _onlineRepeaters = const [];
|
||||
bool _isLoading = true;
|
||||
bool _didFitCamera = false;
|
||||
String? _error;
|
||||
double _currentZoom = _fallbackZoom;
|
||||
LatLng? _myLocation;
|
||||
final Set<String> _addingRepeaters = <String>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRepeaters();
|
||||
}
|
||||
|
||||
Future<void> _loadRepeaters({bool forceRefresh = false}) async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
if (forceRefresh) {
|
||||
_didFitCamera = false;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
final nodes = await MeshMapNodesService.fetchNodes(
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
final repeaters = nodes.where(MeshMapNodesService.isRepeater).toList();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_onlineRepeaters = repeaters;
|
||||
_isLoading = false;
|
||||
});
|
||||
_refreshMyLocation();
|
||||
} catch (error) {
|
||||
final cached = await MeshMapNodesService.loadCachedNodes();
|
||||
final repeaters = cached.where(MeshMapNodesService.isRepeater).toList();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_onlineRepeaters = repeaters;
|
||||
_isLoading = false;
|
||||
_error = repeaters.isEmpty
|
||||
? 'Unable to load repeaters right now.'
|
||||
: 'Showing cached repeaters. Pull to retry.';
|
||||
});
|
||||
_refreshMyLocation();
|
||||
}
|
||||
}
|
||||
|
||||
List<_MapRepeater> _mergeRepeaters({
|
||||
required List<MeshMapNode> onlineNodes,
|
||||
required List<Contact> contactRepeaters,
|
||||
}) {
|
||||
final merged = <String, _MapRepeater>{};
|
||||
|
||||
for (final node in onlineNodes) {
|
||||
merged[node.publicKey] = _MapRepeater(
|
||||
publicKey: node.publicKey,
|
||||
name: node.name,
|
||||
latitude: node.latitude,
|
||||
longitude: node.longitude,
|
||||
isFromContacts: false,
|
||||
isFromOnline: true,
|
||||
);
|
||||
}
|
||||
|
||||
for (final contact in contactRepeaters) {
|
||||
final location = contact.displayLocation;
|
||||
if (location == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final key = contact.publicKeyHex.toLowerCase();
|
||||
final existing = merged[key];
|
||||
merged[key] = _MapRepeater(
|
||||
publicKey: key,
|
||||
name: contact.displayName.isNotEmpty
|
||||
? contact.displayName
|
||||
: existing?.name ?? 'Unknown repeater',
|
||||
latitude: existing?.latitude ?? location.latitude,
|
||||
longitude: existing?.longitude ?? location.longitude,
|
||||
isFromContacts: true,
|
||||
isFromOnline: existing?.isFromOnline ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
final repeaters = merged.values.toList()
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return repeaters;
|
||||
}
|
||||
|
||||
Future<void> _refreshMyLocation() async {
|
||||
try {
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: const LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: 0,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_myLocation = LatLng(position.latitude, position.longitude);
|
||||
});
|
||||
} catch (_) {
|
||||
// Best effort only.
|
||||
}
|
||||
}
|
||||
|
||||
void _fitCameraIfNeeded(List<_MapRepeater> repeaters) {
|
||||
if (_didFitCamera || repeaters.isEmpty || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _didFitCamera || repeaters.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (repeaters.length == 1) {
|
||||
final repeater = repeaters.first;
|
||||
_mapController.move(LatLng(repeater.latitude, repeater.longitude), 13);
|
||||
_currentZoom = 13;
|
||||
} else {
|
||||
final bounds = flutter_map.LatLngBounds.fromPoints(
|
||||
repeaters
|
||||
.map((node) => LatLng(node.latitude, node.longitude))
|
||||
.toList(),
|
||||
);
|
||||
_mapController.fitCamera(
|
||||
flutter_map.CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(48),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_didFitCamera = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _showRepeaterDetails(_MapRepeater repeater) {
|
||||
final isAdding = _addingRepeaters.contains(repeater.publicKey);
|
||||
final canAdd = !repeater.isFromContacts;
|
||||
final isConnected = context
|
||||
.read<ConnectionProvider>()
|
||||
.deviceInfo
|
||||
.isConnected;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
repeater.name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_InfoLine(
|
||||
label: 'Public key',
|
||||
value: repeater.publicKey,
|
||||
monospace: true,
|
||||
),
|
||||
_InfoLine(
|
||||
label: 'Coordinates',
|
||||
value:
|
||||
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
|
||||
),
|
||||
_InfoLine(label: 'Source', value: repeater.sourceLabel),
|
||||
const SizedBox(height: 12),
|
||||
if (canAdd)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: !isConnected || isAdding
|
||||
? null
|
||||
: () async {
|
||||
Navigator.of(context).pop();
|
||||
await _addRepeaterToContacts(repeater);
|
||||
},
|
||||
icon: isAdding
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.person_add_alt_1),
|
||||
label: Text(
|
||||
!isConnected
|
||||
? 'Connect device to add'
|
||||
: 'Add to contacts',
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
onPressed: null,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: const Text('Already in contacts'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addRepeaterToContacts(_MapRepeater repeater) async {
|
||||
if (_addingRepeaters.contains(repeater.publicKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Connect to a device before adding contacts'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_addingRepeaters.add(repeater.publicKey);
|
||||
});
|
||||
|
||||
try {
|
||||
await connectionProvider.getContact(_hexToBytes(repeater.publicKey));
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('${repeater.name} added to contacts')),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Failed to add ${repeater.name}: $error')),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_addingRepeaters.remove(repeater.publicKey);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List _hexToBytes(String hex) {
|
||||
final normalized = hex.replaceAll(':', '').trim().toLowerCase();
|
||||
return Uint8List.fromList(
|
||||
List<int>.generate(
|
||||
normalized.length ~/ 2,
|
||||
(index) => int.parse(
|
||||
normalized.substring(index * 2, index * 2 + 2),
|
||||
radix: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final contactsProvider = context.watch<ContactsProvider>();
|
||||
final repeaters = _mergeRepeaters(
|
||||
onlineNodes: _onlineRepeaters,
|
||||
contactRepeaters: contactsProvider.repeaters,
|
||||
);
|
||||
_fitCameraIfNeeded(repeaters);
|
||||
final clusters = _buildClusters(repeaters);
|
||||
final markers = clusters.map((cluster) {
|
||||
final markerColor = switch (cluster.status) {
|
||||
_ClusterStatus.contactsOnly => const Color(0xFF7C3AED),
|
||||
_ClusterStatus.onlineOnly => const Color(0xFFE8681D),
|
||||
_ClusterStatus.both => const Color(0xFF1B8F4F),
|
||||
_ClusterStatus.mixed => const Color(0xFF2563EB),
|
||||
};
|
||||
|
||||
if (cluster.members.length == 1) {
|
||||
final repeater = cluster.members.first;
|
||||
return flutter_map.Marker(
|
||||
point: cluster.center,
|
||||
width: 80,
|
||||
height: 100,
|
||||
child: GestureDetector(
|
||||
onTap: () => _showRepeaterDetails(repeater),
|
||||
child: _RepeaterMarker(color: markerColor, label: repeater.name),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return flutter_map.Marker(
|
||||
point: cluster.center,
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: GestureDetector(
|
||||
onTap: () => _zoomToCluster(cluster),
|
||||
child: _ClusterMarker(
|
||||
color: markerColor,
|
||||
count: cluster.members.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
final allMarkers = <flutter_map.Marker>[
|
||||
...markers,
|
||||
if (_myLocation != null)
|
||||
flutter_map.Marker(
|
||||
point: _myLocation!,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2563EB),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 3),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.18),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Repeaters Map'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _isLoading
|
||||
? null
|
||||
: () => _loadRepeaters(forceRefresh: true),
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
flutter_map.FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: flutter_map.MapOptions(
|
||||
initialCenter: _fallbackCenter,
|
||||
initialZoom: _fallbackZoom,
|
||||
onPositionChanged: (camera, hasGesture) {
|
||||
final nextZoom = camera.zoom;
|
||||
if (nextZoom != _currentZoom && mounted) {
|
||||
setState(() {
|
||||
_currentZoom = nextZoom;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
flutter_map.TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'app.meshcore.sar',
|
||||
),
|
||||
flutter_map.MarkerLayer(markers: allMarkers),
|
||||
],
|
||||
),
|
||||
if (_error != null)
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Text(_error!),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||
if (!_isLoading && repeaters.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
_error ?? 'No repeaters available.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (repeaters.isNotEmpty)
|
||||
Positioned(
|
||||
left: 16,
|
||||
top: _error == null ? 16 : 72,
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
_LegendRow(
|
||||
color: Color(0xFF7C3AED),
|
||||
label: 'From contacts',
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
_LegendRow(
|
||||
color: Color(0xFFE8681D),
|
||||
label: 'Online only',
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
_LegendRow(color: Color(0xFF1B8F4F), label: 'In both'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (repeaters.isNotEmpty)
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: Material(
|
||||
elevation: 3,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Text(
|
||||
'${repeaters.length} repeaters',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _jumpToMyLocation,
|
||||
tooltip: 'My location',
|
||||
child: const Icon(Icons.my_location),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _jumpToMyLocation() async {
|
||||
await _refreshMyLocation();
|
||||
final myLocation = _myLocation;
|
||||
if (!mounted || myLocation == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_mapController.move(myLocation, _myLocationZoom);
|
||||
setState(() {
|
||||
_currentZoom = _myLocationZoom;
|
||||
});
|
||||
}
|
||||
|
||||
List<_RepeaterCluster> _buildClusters(List<_MapRepeater> repeaters) {
|
||||
if (repeaters.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
if (_currentZoom >= 11.5) {
|
||||
return repeaters
|
||||
.map(
|
||||
(repeater) => _RepeaterCluster(
|
||||
members: [repeater],
|
||||
center: LatLng(repeater.latitude, repeater.longitude),
|
||||
status: repeater.clusterStatus,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final cellSize = _gridSizeForZoom(_currentZoom);
|
||||
final buckets = <String, List<_MapRepeater>>{};
|
||||
|
||||
for (final repeater in repeaters) {
|
||||
final latBucket = (repeater.latitude / cellSize).floor();
|
||||
final lonBucket = (repeater.longitude / cellSize).floor();
|
||||
final key = '$latBucket:$lonBucket';
|
||||
buckets.putIfAbsent(key, () => <_MapRepeater>[]).add(repeater);
|
||||
}
|
||||
|
||||
return buckets.values.map((members) {
|
||||
final centerLat =
|
||||
members.map((node) => node.latitude).reduce((a, b) => a + b) /
|
||||
members.length;
|
||||
final centerLon =
|
||||
members.map((node) => node.longitude).reduce((a, b) => a + b) /
|
||||
members.length;
|
||||
final statuses = members.map((node) => node.clusterStatus).toSet();
|
||||
return _RepeaterCluster(
|
||||
members: members,
|
||||
center: LatLng(centerLat, centerLon),
|
||||
status: statuses.length == 1 ? statuses.first : _ClusterStatus.mixed,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
double _gridSizeForZoom(double zoom) {
|
||||
if (zoom < 5) return 3.0;
|
||||
if (zoom < 7) return 1.5;
|
||||
if (zoom < 9) return 0.7;
|
||||
if (zoom < 10.5) return 0.28;
|
||||
return 0.12;
|
||||
}
|
||||
|
||||
void _zoomToCluster(_RepeaterCluster cluster) {
|
||||
if (cluster.members.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final bounds = flutter_map.LatLngBounds.fromPoints(
|
||||
cluster.members
|
||||
.map((node) => LatLng(node.latitude, node.longitude))
|
||||
.toList(),
|
||||
);
|
||||
_mapController.fitCamera(
|
||||
flutter_map.CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(64),
|
||||
maxZoom: (_currentZoom + 2).clamp(8, 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoLine extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool monospace;
|
||||
|
||||
const _InfoLine({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.monospace = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 92,
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: monospace
|
||||
? const TextStyle(fontFamily: 'monospace')
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LegendRow extends StatelessWidget {
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
const _LegendRow({required this.color, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(label),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RepeaterMarker extends StatelessWidget {
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
const _RepeaterMarker({required this.color, required this.label});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: const Icon(Icons.router, color: Colors.white, size: 18),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 80),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClusterMarker extends StatelessWidget {
|
||||
final Color color;
|
||||
final int count;
|
||||
|
||||
const _ClusterMarker({required this.color, required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.22),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'$count',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _ClusterStatus { contactsOnly, onlineOnly, both, mixed }
|
||||
|
||||
class _RepeaterCluster {
|
||||
final List<_MapRepeater> members;
|
||||
final LatLng center;
|
||||
final _ClusterStatus status;
|
||||
|
||||
const _RepeaterCluster({
|
||||
required this.members,
|
||||
required this.center,
|
||||
required this.status,
|
||||
});
|
||||
}
|
||||
|
||||
class _MapRepeater {
|
||||
final String publicKey;
|
||||
final String name;
|
||||
final double latitude;
|
||||
final double longitude;
|
||||
final bool isFromContacts;
|
||||
final bool isFromOnline;
|
||||
|
||||
const _MapRepeater({
|
||||
required this.publicKey,
|
||||
required this.name,
|
||||
required this.latitude,
|
||||
required this.longitude,
|
||||
required this.isFromContacts,
|
||||
required this.isFromOnline,
|
||||
});
|
||||
|
||||
String get sourceLabel {
|
||||
if (isFromContacts && isFromOnline) return 'Contacts and online';
|
||||
if (isFromContacts) return 'Contacts';
|
||||
return 'Online';
|
||||
}
|
||||
|
||||
_ClusterStatus get clusterStatus {
|
||||
if (isFromContacts && isFromOnline) return _ClusterStatus.both;
|
||||
if (isFromContacts) return _ClusterStatus.contactsOnly;
|
||||
return _ClusterStatus.onlineOnly;
|
||||
}
|
||||
}
|
||||
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
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../providers/connection_provider.dart';
|
||||
import '../services/wizard_preferences.dart';
|
||||
import '../widgets/connection_dialog.dart';
|
||||
|
||||
/// Welcome wizard screen to introduce new users to the app
|
||||
class WelcomeWizardScreen extends StatefulWidget {
|
||||
@@ -14,12 +17,171 @@ class WelcomeWizardScreen extends StatefulWidget {
|
||||
|
||||
class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
final PageController _pageController = PageController();
|
||||
final TextEditingController _deviceNameController = TextEditingController();
|
||||
int _currentPage = 0;
|
||||
bool _isApplyingDeviceSetup = false;
|
||||
static const int _totalPages = 6;
|
||||
static const List<_RadioPreset> _radioPresets = [
|
||||
// Official MeshCore config presets from https://api.meshcore.nz/api/v1/config
|
||||
_RadioPreset(
|
||||
id: 'australia',
|
||||
label: 'Australia',
|
||||
summary: '915.800 MHz, BW 250 kHz, SF10, CR5',
|
||||
frequencyKhz: 915800,
|
||||
bandwidth: 8,
|
||||
spreadingFactor: 10,
|
||||
codingRate: 5,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'australia_narrow',
|
||||
label: 'Australia (Narrow)',
|
||||
summary: '916.575 MHz, BW 62.5 kHz, SF7, CR8',
|
||||
frequencyKhz: 916575,
|
||||
bandwidth: 6,
|
||||
spreadingFactor: 7,
|
||||
codingRate: 8,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'australia_sa_wa',
|
||||
label: 'Australia: SA, WA',
|
||||
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR8',
|
||||
frequencyKhz: 923125,
|
||||
bandwidth: 6,
|
||||
spreadingFactor: 8,
|
||||
codingRate: 8,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'australia_qld',
|
||||
label: 'Australia: QLD',
|
||||
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR5',
|
||||
frequencyKhz: 923125,
|
||||
bandwidth: 6,
|
||||
spreadingFactor: 8,
|
||||
codingRate: 5,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'eu_uk_narrow',
|
||||
label: 'EU/UK (Narrow)',
|
||||
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
|
||||
frequencyKhz: 869618,
|
||||
bandwidth: 6,
|
||||
spreadingFactor: 8,
|
||||
codingRate: 8,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'eu_uk_deprecated',
|
||||
label: 'EU/UK (Deprecated)',
|
||||
summary: '869.525 MHz, BW 250 kHz, SF11, CR5',
|
||||
frequencyKhz: 869525,
|
||||
bandwidth: 8,
|
||||
spreadingFactor: 11,
|
||||
codingRate: 5,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'czech_republic_narrow',
|
||||
label: 'Czech Republic (Narrow)',
|
||||
summary: '869.432 MHz, BW 62.5 kHz, SF7, CR5',
|
||||
frequencyKhz: 869432,
|
||||
bandwidth: 6,
|
||||
spreadingFactor: 7,
|
||||
codingRate: 5,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'eu_433_long_range',
|
||||
label: 'EU 433MHz (Long Range)',
|
||||
summary: '433.650 MHz, BW 250 kHz, SF11, CR5',
|
||||
frequencyKhz: 433650,
|
||||
bandwidth: 8,
|
||||
spreadingFactor: 11,
|
||||
codingRate: 5,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'new_zealand',
|
||||
label: 'New Zealand',
|
||||
summary: '917.375 MHz, BW 250 kHz, SF11, CR5',
|
||||
frequencyKhz: 917375,
|
||||
bandwidth: 8,
|
||||
spreadingFactor: 11,
|
||||
codingRate: 5,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'new_zealand_narrow',
|
||||
label: 'New Zealand (Narrow)',
|
||||
summary: '917.375 MHz, BW 62.5 kHz, SF7, CR5',
|
||||
frequencyKhz: 917375,
|
||||
bandwidth: 6,
|
||||
spreadingFactor: 7,
|
||||
codingRate: 5,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'portugal_433',
|
||||
label: 'Portugal 433',
|
||||
summary: '433.375 MHz, BW 62.5 kHz, SF9, CR6',
|
||||
frequencyKhz: 433375,
|
||||
bandwidth: 6,
|
||||
spreadingFactor: 9,
|
||||
codingRate: 6,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'portugal_868',
|
||||
label: 'Portugal 868',
|
||||
summary: '869.618 MHz, BW 62.5 kHz, SF7, CR6',
|
||||
frequencyKhz: 869618,
|
||||
bandwidth: 6,
|
||||
spreadingFactor: 7,
|
||||
codingRate: 6,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'switzerland',
|
||||
label: 'Switzerland',
|
||||
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
|
||||
frequencyKhz: 869618,
|
||||
bandwidth: 6,
|
||||
spreadingFactor: 8,
|
||||
codingRate: 8,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'usa_canada_recommended',
|
||||
label: 'USA/Canada (Recommended)',
|
||||
summary: '910.525 MHz, BW 62.5 kHz, SF7, CR5',
|
||||
frequencyKhz: 910525,
|
||||
bandwidth: 6,
|
||||
spreadingFactor: 7,
|
||||
codingRate: 5,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'vietnam_narrow',
|
||||
label: 'Vietnam (Narrow)',
|
||||
summary: '920.250 MHz, BW 62.5 kHz, SF8, CR5',
|
||||
frequencyKhz: 920250,
|
||||
bandwidth: 6,
|
||||
spreadingFactor: 8,
|
||||
codingRate: 5,
|
||||
),
|
||||
_RadioPreset(
|
||||
id: 'vietnam_deprecated',
|
||||
label: 'Vietnam (Deprecated)',
|
||||
summary: '920.250 MHz, BW 250 kHz, SF11, CR5',
|
||||
frequencyKhz: 920250,
|
||||
bandwidth: 8,
|
||||
spreadingFactor: 11,
|
||||
codingRate: 5,
|
||||
),
|
||||
];
|
||||
_RadioPreset _selectedPreset = _radioPresets[4];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
|
||||
_deviceNameController.text =
|
||||
deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
_deviceNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -30,7 +192,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
}
|
||||
|
||||
void _nextPage() {
|
||||
if (_currentPage < _totalPages - 1) {
|
||||
if (_currentPage == 1) {
|
||||
_handleDeviceSetupAction();
|
||||
} else if (_currentPage < _totalPages - 1) {
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
@@ -56,6 +220,98 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openConnectionDialog() async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => const ConnectionDialog(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
if (connectionProvider.deviceInfo.isConnected) {
|
||||
await connectionProvider.refreshDeviceInfo();
|
||||
}
|
||||
if (!mounted) return;
|
||||
final deviceInfo = connectionProvider.deviceInfo;
|
||||
final fetchedName = deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
|
||||
if (fetchedName.isNotEmpty) {
|
||||
setState(() {
|
||||
_deviceNameController.text = fetchedName;
|
||||
});
|
||||
} else {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleDeviceSetupAction() async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final deviceInfo = connectionProvider.deviceInfo;
|
||||
if (!deviceInfo.isConnected) {
|
||||
await _openConnectionDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
final trimmedName = _deviceNameController.text.trim();
|
||||
if (trimmedName.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l10n.wizardEnterDeviceName)));
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isApplyingDeviceSetup = true;
|
||||
});
|
||||
|
||||
try {
|
||||
await connectionProvider.setAdvertName(trimmedName);
|
||||
await connectionProvider.setRadioParams(
|
||||
frequency: _selectedPreset.frequencyKhz,
|
||||
bandwidth: _selectedPreset.bandwidth,
|
||||
spreadingFactor: _selectedPreset.spreadingFactor,
|
||||
codingRate: _selectedPreset.codingRate,
|
||||
);
|
||||
await connectionProvider.refreshDeviceInfo();
|
||||
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
l10n.wizardDeviceSetupSaved(trimmedName, _selectedPreset.label),
|
||||
),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.failedToSave(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isApplyingDeviceSetup = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _skipDeviceSetupStep() {
|
||||
if (_isApplyingDeviceSetup) return;
|
||||
_pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -68,7 +324,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
children: [
|
||||
// Top bar with skip button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -96,10 +352,11 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
child: PageView(
|
||||
controller: _pageController,
|
||||
onPageChanged: _onPageChanged,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
_buildWelcomePage(context, l10n, colorScheme),
|
||||
_buildConnectDevicePage(context, l10n, colorScheme),
|
||||
_buildConnectingPage(context, l10n, colorScheme),
|
||||
_buildSimpleModePage(context, l10n, colorScheme),
|
||||
_buildChannelPage(context, l10n, colorScheme),
|
||||
_buildContactsPage(context, l10n, colorScheme),
|
||||
_buildMapPage(context, l10n, colorScheme),
|
||||
@@ -109,7 +366,7 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
|
||||
// Page indicators
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
@@ -131,11 +388,11 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
|
||||
// Next/Get Started button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _nextPage,
|
||||
onPressed: _isApplyingDeviceSetup ? null : _nextPage,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -143,7 +400,9 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_currentPage < _totalPages - 1
|
||||
_currentPage == 1
|
||||
? _deviceSetupButtonLabel(context)
|
||||
: _currentPage < _totalPages - 1
|
||||
? l10n.wizardNext
|
||||
: l10n.wizardGetStarted,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
@@ -166,7 +425,21 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
icon: Icons.waving_hand,
|
||||
iconColor: Colors.orange,
|
||||
title: l10n.wizardWelcomeTitle,
|
||||
description: l10n.wizardWelcomeDescription,
|
||||
description: l10n.wizardOverviewDescription,
|
||||
features: [
|
||||
_FeatureItem(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
text: l10n.wizardOverviewFeature1,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.emergency_share,
|
||||
text: l10n.wizardOverviewFeature2,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.settings_input_antenna,
|
||||
text: l10n.wizardOverviewFeature3,
|
||||
),
|
||||
],
|
||||
colorScheme: colorScheme,
|
||||
);
|
||||
}
|
||||
@@ -177,75 +450,225 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return _buildPage(
|
||||
icon: Icons.bluetooth_searching,
|
||||
icon: Icons.forum_rounded,
|
||||
iconColor: Colors.blue,
|
||||
title: l10n.wizardConnectingTitle,
|
||||
description: l10n.wizardConnectingDescription,
|
||||
title: l10n.wizardMessagingTitle,
|
||||
description: l10n.wizardMessagingDescription,
|
||||
features: [
|
||||
_FeatureItem(
|
||||
icon: Icons.radio,
|
||||
text: l10n.wizardConnectingFeature1,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.link,
|
||||
text: l10n.wizardConnectingFeature2,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.wifi_off,
|
||||
text: l10n.wizardConnectingFeature3,
|
||||
),
|
||||
_FeatureItem(icon: Icons.chat, text: l10n.wizardMessagingFeature1),
|
||||
_FeatureItem(icon: Icons.campaign, text: l10n.wizardMessagingFeature2),
|
||||
_FeatureItem(icon: Icons.mic, text: l10n.wizardMessagingFeature3),
|
||||
],
|
||||
colorScheme: colorScheme,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSimpleModePage(
|
||||
Widget _buildConnectDevicePage(
|
||||
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,
|
||||
final connectionProvider = context.watch<ConnectionProvider>();
|
||||
final deviceInfo = connectionProvider.deviceInfo;
|
||||
final isConnected = deviceInfo.isConnected;
|
||||
final connectedName = deviceInfo.selfName ?? deviceInfo.deviceName;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 600,
|
||||
maxHeight: constraints.maxHeight,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildPageHeader(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
icon: Icons.bluetooth_connected,
|
||||
iconColor: Colors.green,
|
||||
title: l10n.wizardConnectDeviceTitle,
|
||||
description: l10n.wizardConnectDeviceDescription,
|
||||
badge: l10n.wizardSetupBadge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildContentCard(
|
||||
colorScheme: colorScheme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isConnected
|
||||
? Icons.check_circle
|
||||
: Icons.bluetooth_searching,
|
||||
color: isConnected
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isConnected
|
||||
? l10n.wizardConnectedToDevice(
|
||||
connectedName ?? l10n.device,
|
||||
)
|
||||
: l10n.wizardNoDeviceConnected,
|
||||
style: Theme.of(context).textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _openConnectionDialog,
|
||||
child: Text(
|
||||
isConnected ? l10n.change : l10n.connect,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: _skipDeviceSetupStep,
|
||||
child: Text(l10n.wizardSkipForNow),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_buildContentCard(
|
||||
colorScheme: colorScheme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _deviceNameController,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.wizardDeviceNameLabel,
|
||||
border: OutlineInputBorder(),
|
||||
helperText: l10n.wizardDeviceNameHelp,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<_RadioPreset>(
|
||||
initialValue: _selectedPreset,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.wizardConfigRegionLabel,
|
||||
border: OutlineInputBorder(),
|
||||
helperText: l10n.wizardConfigRegionHelp,
|
||||
),
|
||||
items: _radioPresets
|
||||
.map(
|
||||
(preset) => DropdownMenuItem<_RadioPreset>(
|
||||
value: preset,
|
||||
child: Text(
|
||||
preset.label,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (preset) {
|
||||
if (preset == null) return;
|
||||
setState(() {
|
||||
_selectedPreset = preset;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildContentCard(
|
||||
colorScheme: colorScheme,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_selectedPreset.label,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_selectedPreset.summary,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
...[
|
||||
_FeatureItem(
|
||||
icon: Icons.rule,
|
||||
text: l10n.wizardPresetNote1,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.settings_input_antenna,
|
||||
text: l10n.wizardPresetNote2,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.public,
|
||||
text: l10n.wizardPresetNote3,
|
||||
),
|
||||
].map(
|
||||
(feature) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: _buildFeatureRow(
|
||||
context,
|
||||
colorScheme,
|
||||
feature,
|
||||
iconSize: 20,
|
||||
gap: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.settings,
|
||||
text: l10n.wizardSimpleModeFeature2,
|
||||
),
|
||||
],
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _deviceSetupButtonLabel(BuildContext context) {
|
||||
final isConnected = context
|
||||
.watch<ConnectionProvider>()
|
||||
.deviceInfo
|
||||
.isConnected;
|
||||
if (_isApplyingDeviceSetup) {
|
||||
return AppLocalizations.of(context)!.wizardSaving;
|
||||
}
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return isConnected
|
||||
? l10n.wizardSaveAndContinue
|
||||
: l10n.wizardConnectDeviceTitle;
|
||||
}
|
||||
|
||||
Widget _buildChannelPage(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return _buildPage(
|
||||
icon: Icons.campaign,
|
||||
icon: Icons.groups_rounded,
|
||||
iconColor: Colors.purple,
|
||||
title: l10n.wizardChannelTitle,
|
||||
description: l10n.wizardChannelDescription,
|
||||
title: l10n.wizardNetworkTitle,
|
||||
description: l10n.wizardNetworkDescription,
|
||||
features: [
|
||||
_FeatureItem(
|
||||
icon: Icons.public,
|
||||
text: l10n.wizardChannelFeature1,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.groups,
|
||||
text: l10n.wizardChannelFeature2,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.send,
|
||||
text: l10n.wizardChannelFeature3,
|
||||
icon: Icons.person_add_alt_1,
|
||||
text: l10n.wizardNetworkFeature1,
|
||||
),
|
||||
_FeatureItem(icon: Icons.route, text: l10n.wizardNetworkFeature2),
|
||||
_FeatureItem(icon: Icons.hub, text: l10n.wizardNetworkFeature3),
|
||||
],
|
||||
colorScheme: colorScheme,
|
||||
);
|
||||
@@ -257,23 +680,14 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return _buildPage(
|
||||
icon: Icons.people,
|
||||
icon: Icons.map_rounded,
|
||||
iconColor: Colors.teal,
|
||||
title: l10n.wizardContactsTitle,
|
||||
description: l10n.wizardContactsDescription,
|
||||
title: l10n.wizardMapOpsTitle,
|
||||
description: l10n.wizardMapOpsDescription,
|
||||
features: [
|
||||
_FeatureItem(
|
||||
icon: Icons.person_add,
|
||||
text: l10n.wizardContactsFeature1,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.chat,
|
||||
text: l10n.wizardContactsFeature2,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.battery_std,
|
||||
text: l10n.wizardContactsFeature3,
|
||||
),
|
||||
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapOpsFeature1),
|
||||
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapOpsFeature2),
|
||||
_FeatureItem(icon: Icons.router, text: l10n.wizardMapOpsFeature3),
|
||||
],
|
||||
colorScheme: colorScheme,
|
||||
);
|
||||
@@ -285,26 +699,16 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return _buildPage(
|
||||
icon: Icons.map,
|
||||
icon: Icons.tune_rounded,
|
||||
iconColor: Colors.red,
|
||||
title: l10n.wizardMapTitle,
|
||||
description: l10n.wizardMapDescription,
|
||||
title: l10n.wizardToolsTitle,
|
||||
description: l10n.wizardToolsDescription,
|
||||
features: [
|
||||
_FeatureItem(icon: Icons.settings, text: l10n.wizardToolsFeature1),
|
||||
_FeatureItem(icon: Icons.sensors, text: l10n.wizardToolsFeature2),
|
||||
_FeatureItem(
|
||||
icon: Icons.location_on,
|
||||
text: l10n.wizardMapFeature1,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.person_pin_circle,
|
||||
text: l10n.wizardMapFeature2,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.offline_pin,
|
||||
text: l10n.wizardMapFeature3,
|
||||
),
|
||||
_FeatureItem(
|
||||
icon: Icons.draw,
|
||||
text: l10n.wizardMapFeature4,
|
||||
icon: Icons.analytics_outlined,
|
||||
text: l10n.wizardToolsFeature3,
|
||||
),
|
||||
],
|
||||
colorScheme: colorScheme,
|
||||
@@ -319,76 +723,149 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
||||
List<_FeatureItem>? features,
|
||||
required ColorScheme colorScheme,
|
||||
}) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
// Icon
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 80,
|
||||
color: iconColor,
|
||||
),
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 560,
|
||||
maxHeight: constraints.maxHeight,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// Title
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildPageHeader(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
icon: icon,
|
||||
iconColor: iconColor,
|
||||
title: title,
|
||||
description: description,
|
||||
badge: l10n.wizardOverviewBadge,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Description
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
height: 1.5,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (features != null && features.isNotEmpty) ...[
|
||||
const SizedBox(height: 32),
|
||||
// Features list
|
||||
...features.map((feature) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
feature.icon,
|
||||
color: colorScheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Text(
|
||||
feature.text,
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (features != null && features.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
_buildContentCard(
|
||||
colorScheme: colorScheme,
|
||||
child: Column(
|
||||
children: features
|
||||
.map(
|
||||
(feature) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||
child: _buildFeatureRow(
|
||||
context,
|
||||
colorScheme,
|
||||
feature,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageHeader({
|
||||
required BuildContext context,
|
||||
required ColorScheme colorScheme,
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required String title,
|
||||
required String description,
|
||||
required String badge,
|
||||
}) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
badge,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 56, color: iconColor),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.72),
|
||||
height: 1.4,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContentCard({
|
||||
required ColorScheme colorScheme,
|
||||
required Widget child,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureRow(
|
||||
BuildContext context,
|
||||
ColorScheme colorScheme,
|
||||
_FeatureItem feature, {
|
||||
double iconSize = 22,
|
||||
double gap = 14,
|
||||
}) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(feature.icon, color: colorScheme.primary, size: iconSize),
|
||||
SizedBox(width: gap),
|
||||
Expanded(
|
||||
child: Text(
|
||||
feature.text,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeatureItem {
|
||||
@@ -397,3 +874,23 @@ class _FeatureItem {
|
||||
|
||||
_FeatureItem({required this.icon, required this.text});
|
||||
}
|
||||
|
||||
class _RadioPreset {
|
||||
final String id;
|
||||
final String label;
|
||||
final String summary;
|
||||
final int frequencyKhz;
|
||||
final int bandwidth;
|
||||
final int spreadingFactor;
|
||||
final int codingRate;
|
||||
|
||||
const _RadioPreset({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.summary,
|
||||
required this.frequencyKhz,
|
||||
required this.bandwidth,
|
||||
required this.spreadingFactor,
|
||||
required this.codingRate,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -58,6 +59,16 @@ class BackgroundLocationService {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Platform.isIOS && permission != LocationPermission.always) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission != LocationPermission.always) {
|
||||
debugPrint(
|
||||
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefKeyEnabled, true);
|
||||
@@ -68,8 +79,7 @@ class BackgroundLocationService {
|
||||
try {
|
||||
_positionSubscription =
|
||||
Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
locationSettings: _buildLocationSettings(
|
||||
distanceFilter: distanceThreshold.toInt(),
|
||||
),
|
||||
).listen((Position position) async {
|
||||
@@ -171,4 +181,22 @@ class BackgroundLocationService {
|
||||
await startTracking(distanceThreshold: distance);
|
||||
}
|
||||
}
|
||||
|
||||
LocationSettings _buildLocationSettings({int distanceFilter = 10}) {
|
||||
if (Platform.isIOS) {
|
||||
return AppleSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
activityType: ActivityType.fitness,
|
||||
allowBackgroundLocationUpdates: true,
|
||||
distanceFilter: distanceFilter,
|
||||
pauseLocationUpdatesAutomatically: false,
|
||||
showBackgroundLocationIndicator: true,
|
||||
);
|
||||
}
|
||||
|
||||
return LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: distanceFilter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Service for accessing build information from native platform code
|
||||
/// Currently supports Android only - returns "unknown" for other platforms
|
||||
@@ -25,7 +24,7 @@ class BuildInfoService {
|
||||
}
|
||||
|
||||
// Only Android has the platform channel implementation
|
||||
if (!Platform.isAndroid) {
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
||||
_cachedCommitHash = 'unknown';
|
||||
return _cachedCommitHash!;
|
||||
|
||||
336
lib/services/contact_route_resolver.dart
Normal file
336
lib/services/contact_route_resolver.dart
Normal file
@@ -0,0 +1,336 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
|
||||
class ResolvedContactRoutePlan {
|
||||
final List<String> tokens;
|
||||
final List<Contact> selectedContacts;
|
||||
final String summary;
|
||||
|
||||
const ResolvedContactRoutePlan({
|
||||
required this.tokens,
|
||||
required this.selectedContacts,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
String get canonicalText => tokens.join(',');
|
||||
}
|
||||
|
||||
class ContactRouteResolver {
|
||||
static const Distance _distance = Distance();
|
||||
|
||||
const ContactRouteResolver._();
|
||||
|
||||
static ResolvedContactRoutePlan? resolveAutomaticRoute({
|
||||
required LatLng senderLocation,
|
||||
required Contact recipient,
|
||||
required List<Contact> availableContacts,
|
||||
required int hashSize,
|
||||
}) {
|
||||
final recipientLocation = recipient.displayLocation;
|
||||
if (recipientLocation == null) return null;
|
||||
|
||||
final repeaters = availableContacts
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.isRepeater &&
|
||||
contact.displayLocation != null &&
|
||||
contact.publicKeyHex != recipient.publicKeyHex,
|
||||
)
|
||||
.toList();
|
||||
if (repeaters.isEmpty) return null;
|
||||
|
||||
final recipientLatLng = LatLng(
|
||||
recipientLocation.latitude,
|
||||
recipientLocation.longitude,
|
||||
);
|
||||
|
||||
final routedCandidates =
|
||||
repeaters
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.routeHasPath && contact.routeHashSize == hashSize,
|
||||
)
|
||||
.toList()
|
||||
..sort(
|
||||
(a, b) =>
|
||||
_scoreKnownRouteRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: a,
|
||||
availableContacts: repeaters,
|
||||
hashSize: hashSize,
|
||||
).compareTo(
|
||||
_scoreKnownRouteRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: b,
|
||||
availableContacts: repeaters,
|
||||
hashSize: hashSize,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (routedCandidates.isNotEmpty) {
|
||||
final anchor = routedCandidates.first;
|
||||
final tokens = <String>[
|
||||
...anchor.routeCanonicalText
|
||||
.split(',')
|
||||
.where((token) => token.isNotEmpty)
|
||||
.map((token) => token.toUpperCase()),
|
||||
_tokenFor(anchor, hashSize),
|
||||
];
|
||||
final selectedContacts = _matchContactsForTokens(
|
||||
tokens,
|
||||
availableContacts: repeaters,
|
||||
);
|
||||
return ResolvedContactRoutePlan(
|
||||
tokens: _dedupeTokens(tokens),
|
||||
selectedContacts: selectedContacts,
|
||||
summary: 'Resolved via known repeater route',
|
||||
);
|
||||
}
|
||||
|
||||
final corridorRepeaters = List<Contact>.from(repeaters)
|
||||
..sort((a, b) {
|
||||
final progressA = _progressAlongSegment(
|
||||
point: LatLng(
|
||||
a.displayLocation!.latitude,
|
||||
a.displayLocation!.longitude,
|
||||
),
|
||||
start: senderLocation,
|
||||
end: recipientLatLng,
|
||||
);
|
||||
final progressB = _progressAlongSegment(
|
||||
point: LatLng(
|
||||
b.displayLocation!.latitude,
|
||||
b.displayLocation!.longitude,
|
||||
),
|
||||
start: senderLocation,
|
||||
end: recipientLatLng,
|
||||
);
|
||||
final progressCompare = progressA.compareTo(progressB);
|
||||
if (progressCompare != 0) return progressCompare;
|
||||
return _scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: a,
|
||||
).compareTo(
|
||||
_scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLatLng,
|
||||
repeater: b,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
final selected = <Contact>[];
|
||||
var currentPoint = senderLocation;
|
||||
var currentDistanceToRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
senderLocation,
|
||||
recipientLatLng,
|
||||
);
|
||||
final maxCorridorDistance = math.max(
|
||||
1500.0,
|
||||
currentDistanceToRecipient * 0.22,
|
||||
);
|
||||
|
||||
for (final repeater in corridorRepeaters) {
|
||||
if (selected.length >= 4) break;
|
||||
final repeaterPoint = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
final distanceToSegment = _distanceToSegmentMeters(
|
||||
repeaterPoint,
|
||||
senderLocation,
|
||||
recipientLatLng,
|
||||
);
|
||||
if (distanceToSegment > maxCorridorDistance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final nextDistanceToRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
repeaterPoint,
|
||||
recipientLatLng,
|
||||
);
|
||||
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final distanceFromCurrent = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
currentPoint,
|
||||
repeaterPoint,
|
||||
);
|
||||
if (distanceFromCurrent < 100) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.add(repeater);
|
||||
currentPoint = repeaterPoint;
|
||||
currentDistanceToRecipient = nextDistanceToRecipient;
|
||||
|
||||
if (currentDistanceToRecipient < 2500) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ResolvedContactRoutePlan(
|
||||
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
|
||||
selectedContacts: selected,
|
||||
summary: 'Resolved from repeater locations',
|
||||
);
|
||||
}
|
||||
|
||||
static List<Contact> _matchContactsForTokens(
|
||||
List<String> tokens, {
|
||||
required List<Contact> availableContacts,
|
||||
}) {
|
||||
final matches = <Contact>[];
|
||||
final seen = <String>{};
|
||||
for (final token in tokens) {
|
||||
final match = availableContacts
|
||||
.where(
|
||||
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||
)
|
||||
.firstOrNull;
|
||||
if (match != null && seen.add(match.publicKeyHex)) {
|
||||
matches.add(match);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
static List<String> _dedupeTokens(List<String> tokens) {
|
||||
final result = <String>[];
|
||||
for (final token in tokens) {
|
||||
if (result.isEmpty || result.last != token) {
|
||||
result.add(token);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static String _tokenFor(Contact contact, int hashSize) {
|
||||
final hex = contact.publicKeyHex.toUpperCase();
|
||||
final length = hashSize * 2;
|
||||
return hex.length < length ? hex : hex.substring(0, length);
|
||||
}
|
||||
|
||||
static double _scoreRepeater({
|
||||
required LatLng senderLocation,
|
||||
required LatLng recipientLocation,
|
||||
required Contact repeater,
|
||||
}) {
|
||||
final point = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
final toRecipient = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
point,
|
||||
recipientLocation,
|
||||
);
|
||||
final corridor = _distanceToSegmentMeters(
|
||||
point,
|
||||
senderLocation,
|
||||
recipientLocation,
|
||||
);
|
||||
return (toRecipient * 0.75) + (corridor * 0.25);
|
||||
}
|
||||
|
||||
static double _scoreKnownRouteRepeater({
|
||||
required LatLng senderLocation,
|
||||
required LatLng recipientLocation,
|
||||
required Contact repeater,
|
||||
required List<Contact> availableContacts,
|
||||
required int hashSize,
|
||||
}) {
|
||||
final baseScore = _scoreRepeater(
|
||||
senderLocation: senderLocation,
|
||||
recipientLocation: recipientLocation,
|
||||
repeater: repeater,
|
||||
);
|
||||
final repeaterPoint = LatLng(
|
||||
repeater.displayLocation!.latitude,
|
||||
repeater.displayLocation!.longitude,
|
||||
);
|
||||
|
||||
final chainContacts = repeater.routeCanonicalText
|
||||
.split(',')
|
||||
.where((token) => token.isNotEmpty)
|
||||
.map(
|
||||
(token) => availableContacts
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.displayLocation != null &&
|
||||
contact.publicKeyHex.toUpperCase().startsWith(
|
||||
token.toUpperCase(),
|
||||
),
|
||||
)
|
||||
.firstOrNull,
|
||||
)
|
||||
.whereType<Contact>()
|
||||
.toList();
|
||||
|
||||
final chainEnd = chainContacts.isNotEmpty
|
||||
? LatLng(
|
||||
chainContacts.last.displayLocation!.latitude,
|
||||
chainContacts.last.displayLocation!.longitude,
|
||||
)
|
||||
: senderLocation;
|
||||
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
|
||||
final senderGap = _distance.as(
|
||||
LengthUnit.Meter,
|
||||
senderLocation,
|
||||
repeaterPoint,
|
||||
);
|
||||
|
||||
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
|
||||
}
|
||||
|
||||
static double _progressAlongSegment({
|
||||
required LatLng point,
|
||||
required LatLng start,
|
||||
required LatLng end,
|
||||
}) {
|
||||
final dx = end.longitude - start.longitude;
|
||||
final dy = end.latitude - start.latitude;
|
||||
final lengthSquared = (dx * dx) + (dy * dy);
|
||||
if (lengthSquared == 0) return 0;
|
||||
return (((point.longitude - start.longitude) * dx) +
|
||||
((point.latitude - start.latitude) * dy)) /
|
||||
lengthSquared;
|
||||
}
|
||||
|
||||
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||
final ax = a.longitude;
|
||||
final ay = a.latitude;
|
||||
final bx = b.longitude;
|
||||
final by = b.latitude;
|
||||
final px = p.longitude;
|
||||
final py = p.latitude;
|
||||
|
||||
final abx = bx - ax;
|
||||
final aby = by - ay;
|
||||
final apx = px - ax;
|
||||
final apy = py - ay;
|
||||
final ab2 = abx * abx + aby * aby;
|
||||
if (ab2 == 0) {
|
||||
return _distance.as(LengthUnit.Meter, a, p);
|
||||
}
|
||||
var t = (apx * abx + apy * aby) / ab2;
|
||||
t = t.clamp(0.0, 1.0);
|
||||
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||
return _distance.as(LengthUnit.Meter, closest, p);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,17 @@ import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_group.dart';
|
||||
import '../utils/key_comparison.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Service for persisting contacts to local storage
|
||||
class ContactStorageService {
|
||||
static const String _contactsKey = 'stored_contacts';
|
||||
static const String _contactGroupsKey = 'stored_contact_groups';
|
||||
static const String _pendingAdvertsKey = 'stored_pending_adverts';
|
||||
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
||||
static const int _maxStoredPendingAdverts = 500;
|
||||
|
||||
/// Save contacts to persistent storage
|
||||
Future<void> saveContacts(List<Contact> contacts) async {
|
||||
@@ -90,6 +94,81 @@ class ContactStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveContactGroups(List<SavedContactGroup> groups) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = jsonEncode(
|
||||
groups.map((group) => _contactGroupToJson(group)).toList(),
|
||||
);
|
||||
await prefs.setString(_contactGroupsKey, jsonString);
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SavedContactGroup>> loadContactGroups() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_contactGroupsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
return jsonList
|
||||
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
|
||||
.whereType<SavedContactGroup>()
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> savePendingAdverts(List<Map<String, dynamic>> adverts) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final limitedList = adverts.length > _maxStoredPendingAdverts
|
||||
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
|
||||
: adverts;
|
||||
await prefs.setString(_pendingAdvertsKey, jsonEncode(limitedList));
|
||||
debugPrint(
|
||||
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> loadPendingAdverts() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_pendingAdvertsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||
return jsonList.whereType<Map<String, dynamic>>().toList();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearPendingAdverts() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_pendingAdvertsKey);
|
||||
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats() async {
|
||||
try {
|
||||
@@ -123,6 +202,7 @@ class ContactStorageService {
|
||||
'outPathLen': contact.outPathLen,
|
||||
'outPath': base64Encode(contact.outPath),
|
||||
'advName': contact.advName,
|
||||
'nameOverride': contact.nameOverride,
|
||||
'lastAdvert': contact.lastAdvert,
|
||||
'advLat': contact.advLat,
|
||||
'advLon': contact.advLon,
|
||||
@@ -130,6 +210,15 @@ class ContactStorageService {
|
||||
'telemetry': contact.telemetry != null
|
||||
? _telemetryToJson(contact.telemetry!)
|
||||
: null,
|
||||
'advertHistory': contact.advertHistory
|
||||
.map(
|
||||
(point) => {
|
||||
'lat': point.location.latitude,
|
||||
'lon': point.location.longitude,
|
||||
'tsMillis': point.timestamp.millisecondsSinceEpoch,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,6 +234,7 @@ class ContactStorageService {
|
||||
outPathLen: json['outPathLen'] as int,
|
||||
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
|
||||
advName: json['advName'] as String,
|
||||
nameOverride: json['nameOverride'] as String?,
|
||||
lastAdvert: json['lastAdvert'] as int,
|
||||
advLat: json['advLat'] as int,
|
||||
advLon: json['advLon'] as int,
|
||||
@@ -152,6 +242,7 @@ class ContactStorageService {
|
||||
telemetry: json['telemetry'] != null
|
||||
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
|
||||
: null,
|
||||
advertHistory: _advertHistoryFromJson(json['advertHistory']),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
|
||||
@@ -203,4 +294,56 @@ class ContactStorageService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
List<AdvertLocation> _advertHistoryFromJson(dynamic json) {
|
||||
if (json is! List) return [];
|
||||
final result = <AdvertLocation>[];
|
||||
for (final item in json) {
|
||||
if (item is! Map<String, dynamic>) continue;
|
||||
final lat = item['lat'];
|
||||
final lon = item['lon'];
|
||||
final tsMillis = item['tsMillis'];
|
||||
if (lat is! num || lon is! num || tsMillis is! int) continue;
|
||||
result.add(
|
||||
AdvertLocation(
|
||||
location: LatLng(lat.toDouble(), lon.toDouble()),
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis),
|
||||
),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
||||
return {
|
||||
'id': group.id,
|
||||
'sectionKey': group.sectionKey,
|
||||
'label': group.label,
|
||||
'query': group.query,
|
||||
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
||||
'matchPrefixes': group.matchPrefixes,
|
||||
'isAutoGroup': group.isAutoGroup,
|
||||
};
|
||||
}
|
||||
|
||||
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
return SavedContactGroup(
|
||||
id: json['id'] as String,
|
||||
sectionKey: json['sectionKey'] as String,
|
||||
label: json['label'] as String,
|
||||
query: json['query'] as String,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
json['createdAtMillis'] as int,
|
||||
),
|
||||
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
|
||||
?.map((value) => value as String)
|
||||
.toList(),
|
||||
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
247
lib/services/live_traffic_summary.dart
Normal file
247
lib/services/live_traffic_summary.dart
Normal file
@@ -0,0 +1,247 @@
|
||||
import '../models/ble_packet_log.dart';
|
||||
import '../utils/log_rx_route_decoder.dart';
|
||||
|
||||
enum LiveTrafficBusyness { quiet, active, busy }
|
||||
|
||||
class LiveTrafficEntry {
|
||||
final BlePacketLog log;
|
||||
final DecodedLogRxRoute? route;
|
||||
|
||||
const LiveTrafficEntry({required this.log, required this.route});
|
||||
|
||||
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
|
||||
|
||||
int? get hopCount => route?.hopCount;
|
||||
|
||||
String get payloadLabel {
|
||||
final decodedRoute = route;
|
||||
if (decodedRoute == null) {
|
||||
return log.responseCode != null ? log.opcodeName : 'Unknown';
|
||||
}
|
||||
return payloadTypeLabel(decodedRoute.payloadType);
|
||||
}
|
||||
|
||||
String? get payloadMeaning {
|
||||
final decodedRoute = route;
|
||||
if (decodedRoute == null) return null;
|
||||
return payloadTypeMeaning(decodedRoute.payloadType);
|
||||
}
|
||||
|
||||
String get routePreview {
|
||||
final decodedRoute = route;
|
||||
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
|
||||
return 'Direct packet';
|
||||
}
|
||||
return decodedRoute.hopHashes
|
||||
.map((hashHex) => '0x${hashHex.toUpperCase()}')
|
||||
.join(' -> ');
|
||||
}
|
||||
|
||||
static String payloadTypeLabel(int payloadType) {
|
||||
switch (payloadType) {
|
||||
case 0x00:
|
||||
return 'Request';
|
||||
case 0x01:
|
||||
return 'Response';
|
||||
case 0x02:
|
||||
return 'Text message';
|
||||
case 0x03:
|
||||
return 'Ack';
|
||||
case 0x04:
|
||||
return 'Advertisement';
|
||||
case 0x05:
|
||||
return 'Group text';
|
||||
case 0x06:
|
||||
return 'Group datagram';
|
||||
case 0x07:
|
||||
return 'Anonymous request';
|
||||
case 0x08:
|
||||
return 'Returned path';
|
||||
case 0x09:
|
||||
return 'Trace path';
|
||||
case 0x0A:
|
||||
return 'Multipart packet';
|
||||
case 0x0B:
|
||||
return 'Control packet';
|
||||
default:
|
||||
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
static String payloadTypeMeaning(int payloadType) {
|
||||
switch (payloadType) {
|
||||
case 0x00:
|
||||
return 'Request (destination/source hashes + MAC)';
|
||||
case 0x01:
|
||||
return 'Response to Request or Anonymous request';
|
||||
case 0x02:
|
||||
return 'Plain text message';
|
||||
case 0x03:
|
||||
return 'Simple acknowledgement';
|
||||
case 0x04:
|
||||
return 'Node advertisement';
|
||||
case 0x05:
|
||||
return 'Unverified group text message';
|
||||
case 0x06:
|
||||
return 'Unverified group datagram';
|
||||
case 0x07:
|
||||
return 'Generic anonymous request';
|
||||
case 0x08:
|
||||
return 'Returned path payload';
|
||||
case 0x09:
|
||||
return 'Trace path collecting hop SNR';
|
||||
case 0x0A:
|
||||
return 'One packet from a multipart set';
|
||||
case 0x0B:
|
||||
return 'Control or discovery packet';
|
||||
default:
|
||||
return 'protocol payload';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class LiveTrafficSnapshot {
|
||||
final DateTime windowStart;
|
||||
final Duration windowDuration;
|
||||
final int packetsPerMinute;
|
||||
final int rxCount;
|
||||
final int txCount;
|
||||
final int totalCount;
|
||||
final double? avgSnrDb;
|
||||
final double? latestSnrDb;
|
||||
final double? avgRssiDbm;
|
||||
final int? latestRssiDbm;
|
||||
final int multiHopCount;
|
||||
final double? avgHopCount;
|
||||
final List<LiveTrafficEntry> visibleEntries;
|
||||
final LiveTrafficBusyness busyness;
|
||||
|
||||
const LiveTrafficSnapshot({
|
||||
required this.windowStart,
|
||||
required this.windowDuration,
|
||||
required this.packetsPerMinute,
|
||||
required this.rxCount,
|
||||
required this.txCount,
|
||||
required this.totalCount,
|
||||
required this.avgSnrDb,
|
||||
required this.latestSnrDb,
|
||||
required this.avgRssiDbm,
|
||||
required this.latestRssiDbm,
|
||||
required this.multiHopCount,
|
||||
required this.avgHopCount,
|
||||
required this.visibleEntries,
|
||||
required this.busyness,
|
||||
});
|
||||
}
|
||||
|
||||
class LiveTrafficSummary {
|
||||
static const Duration rollingWindow = Duration(seconds: 60);
|
||||
static const int maxVisibleEntries = 120;
|
||||
static const int logRxDataResponseCode = 0x88;
|
||||
|
||||
const LiveTrafficSummary._();
|
||||
|
||||
static LiveTrafficSnapshot fromLogs(
|
||||
Iterable<BlePacketLog> logs, {
|
||||
required DateTime now,
|
||||
DateTime? clearedAt,
|
||||
int? preferredHashSize,
|
||||
Duration window = rollingWindow,
|
||||
String? packetTypeFilter,
|
||||
}) {
|
||||
final windowStart = now.subtract(window);
|
||||
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
|
||||
? clearedAt
|
||||
: windowStart;
|
||||
|
||||
final recentLogs = logs
|
||||
.where(
|
||||
(log) =>
|
||||
log.direction == PacketDirection.rx &&
|
||||
log.responseCode == logRxDataResponseCode &&
|
||||
!log.timestamp.isBefore(effectiveStart),
|
||||
)
|
||||
.toList()
|
||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
|
||||
final entries = <LiveTrafficEntry>[];
|
||||
for (final log in recentLogs) {
|
||||
final route = LogRxRouteDecoder.decode(
|
||||
log.rawData,
|
||||
preferredHashSize: preferredHashSize,
|
||||
);
|
||||
entries.add(LiveTrafficEntry(log: log, route: route));
|
||||
}
|
||||
|
||||
final filteredEntries = packetTypeFilter == null
|
||||
? entries
|
||||
: entries
|
||||
.where((entry) => entry.payloadLabel == packetTypeFilter)
|
||||
.toList();
|
||||
|
||||
var rxCount = 0;
|
||||
var snrCount = 0;
|
||||
var snrSum = 0.0;
|
||||
var rssiCount = 0;
|
||||
var rssiSum = 0.0;
|
||||
double? latestSnrDb;
|
||||
int? latestRssiDbm;
|
||||
var multiHopCount = 0;
|
||||
var hopCountTotal = 0;
|
||||
var hopCountSamples = 0;
|
||||
|
||||
for (final entry in filteredEntries) {
|
||||
rxCount += 1;
|
||||
|
||||
final rxInfo = entry.log.logRxDataInfo;
|
||||
if (rxInfo?.snrDb != null) {
|
||||
snrCount += 1;
|
||||
snrSum += rxInfo!.snrDb!;
|
||||
latestSnrDb = rxInfo.snrDb!;
|
||||
}
|
||||
if (rxInfo?.rssiDbm != null) {
|
||||
rssiCount += 1;
|
||||
rssiSum += rxInfo!.rssiDbm!.toDouble();
|
||||
latestRssiDbm = rxInfo.rssiDbm!;
|
||||
}
|
||||
|
||||
final route = entry.route;
|
||||
if (route != null && route.hopCount > 0) {
|
||||
hopCountSamples += 1;
|
||||
hopCountTotal += route.hopCount;
|
||||
if (route.hopCount > 1) {
|
||||
multiHopCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
|
||||
const txCount = 0;
|
||||
final totalCount = rxCount;
|
||||
final packetsPerMinute =
|
||||
((totalCount * Duration.secondsPerMinute) / window.inSeconds).round();
|
||||
|
||||
return LiveTrafficSnapshot(
|
||||
windowStart: effectiveStart,
|
||||
windowDuration: window,
|
||||
packetsPerMinute: packetsPerMinute,
|
||||
rxCount: rxCount,
|
||||
txCount: txCount,
|
||||
totalCount: totalCount,
|
||||
avgSnrDb: snrCount == 0 ? null : snrSum / snrCount,
|
||||
latestSnrDb: latestSnrDb,
|
||||
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
|
||||
latestRssiDbm: latestRssiDbm,
|
||||
multiHopCount: multiHopCount,
|
||||
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
|
||||
visibleEntries: visibleEntries,
|
||||
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
|
||||
);
|
||||
}
|
||||
|
||||
static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) {
|
||||
if (ppm <= 5) return LiveTrafficBusyness.quiet;
|
||||
if (ppm <= 20) return LiveTrafficBusyness.active;
|
||||
return LiveTrafficBusyness.busy;
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@ class LocalePreferences {
|
||||
Locale('fr'), // French
|
||||
Locale('it'), // Italian
|
||||
Locale('el'), // Greek
|
||||
Locale('pt'), // Portuguese
|
||||
Locale('tr'), // Turkish
|
||||
Locale('pl'), // Polish
|
||||
Locale('uk'), // Ukrainian
|
||||
Locale('ru'), // Russian
|
||||
Locale('zh'), // Chinese
|
||||
];
|
||||
|
||||
/// Get the saved locale or return null to use system locale
|
||||
@@ -64,7 +70,17 @@ class LocalePreferences {
|
||||
return 'Italiano';
|
||||
case 'el':
|
||||
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 '简体中文';
|
||||
default:
|
||||
return locale.languageCode;
|
||||
@@ -90,7 +106,17 @@ class LocalePreferences {
|
||||
return 'Italiano';
|
||||
case 'el':
|
||||
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 '简体中文';
|
||||
default:
|
||||
return locale.languageCode;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -41,6 +42,12 @@ class LocationTrackingService {
|
||||
static const String _prefKeyGpsUpdateDistance = 'map_gps_update_distance';
|
||||
static const String _prefKeyLastLat = 'background_last_lat';
|
||||
static const String _prefKeyLastLon = 'background_last_lon';
|
||||
static const String _prefKeyFastLocationEnabled =
|
||||
'fast_location_updates_enabled';
|
||||
static const String _prefKeyFastMovementThreshold =
|
||||
'fast_location_movement_threshold_meters';
|
||||
static const String _prefKeyFastActiveCadence =
|
||||
'fast_location_active_cadence_seconds';
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Properties
|
||||
@@ -58,6 +65,15 @@ class LocationTrackingService {
|
||||
/// GPS update distance filter for position stream
|
||||
double gpsUpdateDistance = 10.0;
|
||||
|
||||
/// Whether private fast GPS updates are enabled
|
||||
bool fastLocationUpdatesEnabled = false;
|
||||
|
||||
/// Distance threshold for fast GPS updates
|
||||
double fastLocationMovementThresholdMeters = 10.0;
|
||||
|
||||
/// Cadence for active-use fast GPS updates
|
||||
int fastLocationActiveCadenceSeconds = 10;
|
||||
|
||||
// ============================================================================
|
||||
// State Properties
|
||||
// ============================================================================
|
||||
@@ -84,6 +100,11 @@ class LocationTrackingService {
|
||||
/// Position stream subscription
|
||||
StreamSubscription<Position>? _positionSubscription;
|
||||
|
||||
Timer? _fastLocationTimer;
|
||||
bool _isFastLocationActiveUse = false;
|
||||
DateTime? _lastFastLocationSentAt;
|
||||
Position? _lastFastLocationSentPosition;
|
||||
|
||||
// ============================================================================
|
||||
// Callback Properties
|
||||
// ============================================================================
|
||||
@@ -100,6 +121,9 @@ class LocationTrackingService {
|
||||
/// Called when tracking state changes
|
||||
void Function(bool isTracking)? onTrackingStateChanged;
|
||||
|
||||
/// Called when a fast private GPS update should be sent
|
||||
void Function(Position position, String reason)? onFastLocationUpdate;
|
||||
|
||||
// ============================================================================
|
||||
// Initialization
|
||||
// ============================================================================
|
||||
@@ -125,6 +149,10 @@ class LocationTrackingService {
|
||||
/// Check if location permissions are granted
|
||||
Future<bool> checkPermissions() async {
|
||||
final permission = await Geolocator.checkPermission();
|
||||
if (Platform.isIOS) {
|
||||
return permission == LocationPermission.always;
|
||||
}
|
||||
|
||||
return permission == LocationPermission.always ||
|
||||
permission == LocationPermission.whileInUse;
|
||||
}
|
||||
@@ -158,6 +186,16 @@ class LocationTrackingService {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Platform.isIOS && permission != LocationPermission.always) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
if (permission != LocationPermission.always) {
|
||||
onError?.call(
|
||||
'Background tracking on iOS requires "Always" location access. Enable it in Settings.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('✅ [LocationTracking] Location permissions granted');
|
||||
return true;
|
||||
}
|
||||
@@ -178,35 +216,45 @@ class LocationTrackingService {
|
||||
for (int attempt = 0; attempt <= retryCount; attempt++) {
|
||||
try {
|
||||
if (attempt > 0) {
|
||||
debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount');
|
||||
debugPrint(
|
||||
'🔄 [LocationTracking] Retry attempt $attempt/$retryCount',
|
||||
);
|
||||
// Exponential backoff: wait 2^attempt seconds before retry
|
||||
await Future.delayed(Duration(seconds: 1 << attempt));
|
||||
}
|
||||
|
||||
final position = await Geolocator.getCurrentPosition(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
locationSettings: _buildLocationSettings(
|
||||
timeLimit: timeLimit,
|
||||
distanceFilter: 0,
|
||||
),
|
||||
);
|
||||
|
||||
currentPosition = position;
|
||||
if (attempt > 0) {
|
||||
debugPrint('✅ [LocationTracking] Position acquired after $attempt retries');
|
||||
debugPrint(
|
||||
'✅ [LocationTracking] Position acquired after $attempt retries',
|
||||
);
|
||||
}
|
||||
return position;
|
||||
} catch (e) {
|
||||
final isLastAttempt = attempt == retryCount;
|
||||
if (isLastAttempt) {
|
||||
debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e');
|
||||
debugPrint(
|
||||
'❌ [LocationTracking] Failed to get position after $retryCount retries: $e',
|
||||
);
|
||||
// Only call error callback on final failure, and make it user-friendly
|
||||
if (e.toString().contains('TimeoutException')) {
|
||||
onError?.call('GPS signal weak. Position stream will continue trying...');
|
||||
onError?.call(
|
||||
'GPS signal weak. Position stream will continue trying...',
|
||||
);
|
||||
} else {
|
||||
onError?.call('Failed to get GPS position. Check device settings.');
|
||||
}
|
||||
} else {
|
||||
debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e');
|
||||
debugPrint(
|
||||
'⚠️ [LocationTracking] Position attempt $attempt failed: $e',
|
||||
);
|
||||
}
|
||||
|
||||
if (isLastAttempt) {
|
||||
@@ -222,13 +270,35 @@ class LocationTrackingService {
|
||||
/// [distanceFilter] - Minimum distance in meters between position updates
|
||||
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
|
||||
return Geolocator.getPositionStream(
|
||||
locationSettings: LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
locationSettings: _buildLocationSettings(
|
||||
distanceFilter: distanceFilter.toInt(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
LocationSettings _buildLocationSettings({
|
||||
int distanceFilter = 10,
|
||||
Duration? timeLimit,
|
||||
}) {
|
||||
if (Platform.isIOS) {
|
||||
return AppleSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
activityType: ActivityType.fitness,
|
||||
allowBackgroundLocationUpdates: true,
|
||||
distanceFilter: distanceFilter,
|
||||
pauseLocationUpdatesAutomatically: false,
|
||||
showBackgroundLocationIndicator: true,
|
||||
timeLimit: timeLimit,
|
||||
);
|
||||
}
|
||||
|
||||
return LocationSettings(
|
||||
accuracy: LocationAccuracy.best,
|
||||
distanceFilter: distanceFilter,
|
||||
timeLimit: timeLimit,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tracking Control
|
||||
// ============================================================================
|
||||
@@ -244,16 +314,16 @@ class LocationTrackingService {
|
||||
/// GPS tracking works WITHOUT BLE connection - device broadcasts are simply skipped.
|
||||
Future<bool> startTracking({double? distanceThreshold}) async {
|
||||
if (!_isInitialized) {
|
||||
debugPrint(
|
||||
'⚠️ [LocationTracking] Service not initialized',
|
||||
);
|
||||
debugPrint('⚠️ [LocationTracking] Service not initialized');
|
||||
onError?.call('Location tracking service not initialized');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow tracking without BLE connection - broadcasts will be skipped
|
||||
if (_bleService == null || !_bleService!.isConnected) {
|
||||
debugPrint('ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)');
|
||||
debugPrint(
|
||||
'ℹ️ [LocationTracking] Starting GPS tracking without BLE connection (broadcasts disabled)',
|
||||
);
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
@@ -271,17 +341,20 @@ class LocationTrackingService {
|
||||
|
||||
// Try to get initial position in background (non-blocking)
|
||||
// This will populate currentPosition but won't block tracking startup
|
||||
getCurrentPosition(
|
||||
timeLimit: const Duration(seconds: 10),
|
||||
retryCount: 1,
|
||||
).then((position) {
|
||||
if (position != null) {
|
||||
debugPrint('✅ [LocationTracking] Initial position acquired in background');
|
||||
}
|
||||
}).catchError((error) {
|
||||
debugPrint('⚠️ [LocationTracking] Background initial position failed: $error');
|
||||
// Not critical - position stream will eventually provide position
|
||||
});
|
||||
getCurrentPosition(timeLimit: const Duration(seconds: 10), retryCount: 1)
|
||||
.then((position) {
|
||||
if (position != null) {
|
||||
debugPrint(
|
||||
'✅ [LocationTracking] Initial position acquired in background',
|
||||
);
|
||||
}
|
||||
})
|
||||
.catchError((error) {
|
||||
debugPrint(
|
||||
'⚠️ [LocationTracking] Background initial position failed: $error',
|
||||
);
|
||||
// Not critical - position stream will eventually provide position
|
||||
});
|
||||
|
||||
// Start position stream immediately (don't wait for initial position)
|
||||
try {
|
||||
@@ -296,6 +369,7 @@ class LocationTrackingService {
|
||||
|
||||
isTracking = true;
|
||||
onTrackingStateChanged?.call(true);
|
||||
_refreshFastLocationTimer();
|
||||
|
||||
debugPrint(
|
||||
'✅ [LocationTracking] Tracking started with ${threshold}m threshold',
|
||||
@@ -318,6 +392,7 @@ class LocationTrackingService {
|
||||
|
||||
isTracking = false;
|
||||
onTrackingStateChanged?.call(false);
|
||||
_refreshFastLocationTimer();
|
||||
|
||||
// Reset first position flag so next connection starts fresh
|
||||
_firstPositionSet = false;
|
||||
@@ -361,6 +436,8 @@ class LocationTrackingService {
|
||||
// Notify listeners
|
||||
onPositionUpdate?.call(position);
|
||||
|
||||
_evaluateFastLocationMovement(position);
|
||||
|
||||
// SPECIAL CASE: First stable position after connection
|
||||
// Set lat/lon on device WITHOUT broadcasting to mesh network
|
||||
if (!_firstPositionSet) {
|
||||
@@ -378,12 +455,16 @@ class LocationTrackingService {
|
||||
/// Updates the device's advertised lat/lon but does NOT send an advertisement.
|
||||
void _setInitialPosition(Position position) async {
|
||||
if (_bleService == null || !_bleService!.isConnected) {
|
||||
debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected');
|
||||
debugPrint(
|
||||
'⚠️ [LocationTracking] Cannot set initial position: BLE not connected',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)');
|
||||
debugPrint(
|
||||
'📍 [LocationTracking] Setting initial position (no broadcast)',
|
||||
);
|
||||
|
||||
// Update device's advertised location WITHOUT sending advertisement
|
||||
await _bleService!.setAdvertLatLon(
|
||||
@@ -414,7 +495,94 @@ class LocationTrackingService {
|
||||
void _checkAndBroadcast(Position position) {
|
||||
// Automatic broadcasting disabled
|
||||
// Use the manual advert button instead
|
||||
debugPrint(' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)');
|
||||
debugPrint(
|
||||
' ⏸️ [LocationTracking] Automatic broadcasting disabled (use advert button)',
|
||||
);
|
||||
}
|
||||
|
||||
void setFastLocationActiveUse(bool isActive) {
|
||||
if (_isFastLocationActiveUse == isActive) return;
|
||||
_isFastLocationActiveUse = isActive;
|
||||
_refreshFastLocationTimer();
|
||||
}
|
||||
|
||||
Future<void> setFastLocationUpdatesEnabled(bool enabled) async {
|
||||
fastLocationUpdatesEnabled = enabled;
|
||||
await saveSettings();
|
||||
_refreshFastLocationTimer();
|
||||
}
|
||||
|
||||
Future<void> updateFastLocationMovementThreshold(double meters) async {
|
||||
fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0);
|
||||
await saveSettings();
|
||||
}
|
||||
|
||||
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
|
||||
fastLocationActiveCadenceSeconds = seconds.clamp(5, 60);
|
||||
await saveSettings();
|
||||
_refreshFastLocationTimer();
|
||||
}
|
||||
|
||||
void _evaluateFastLocationMovement(Position position) {
|
||||
if (!fastLocationUpdatesEnabled) return;
|
||||
final previous = _lastFastLocationSentPosition;
|
||||
if (previous == null) {
|
||||
_emitFastLocationUpdate(position, reason: 'initial');
|
||||
return;
|
||||
}
|
||||
|
||||
final distance = Geolocator.distanceBetween(
|
||||
previous.latitude,
|
||||
previous.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
if (distance >= fastLocationMovementThresholdMeters) {
|
||||
_emitFastLocationUpdate(position, reason: 'movement');
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshFastLocationTimer() {
|
||||
_fastLocationTimer?.cancel();
|
||||
_fastLocationTimer = null;
|
||||
if (!isTracking ||
|
||||
!fastLocationUpdatesEnabled ||
|
||||
!_isFastLocationActiveUse) {
|
||||
return;
|
||||
}
|
||||
|
||||
_fastLocationTimer = Timer.periodic(
|
||||
Duration(seconds: fastLocationActiveCadenceSeconds),
|
||||
(_) {
|
||||
final position = currentPosition;
|
||||
if (position == null) return;
|
||||
_emitFastLocationUpdate(position, reason: 'active_use');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _emitFastLocationUpdate(Position position, {required String reason}) {
|
||||
if (!fastLocationUpdatesEnabled) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
final previous = _lastFastLocationSentPosition;
|
||||
final previousTime = _lastFastLocationSentAt;
|
||||
if (previous != null && previousTime != null) {
|
||||
final distance = Geolocator.distanceBetween(
|
||||
previous.latitude,
|
||||
previous.longitude,
|
||||
position.latitude,
|
||||
position.longitude,
|
||||
);
|
||||
final elapsedMs = now.difference(previousTime).inMilliseconds;
|
||||
if (distance < 1.0 && elapsedMs < 3000) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_lastFastLocationSentPosition = position;
|
||||
_lastFastLocationSentAt = now;
|
||||
onFastLocationUpdate?.call(position, reason);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -457,7 +625,9 @@ class LocationTrackingService {
|
||||
await _bleService!.sendSelfAdvert(floodMode: true);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Manual broadcast successful');
|
||||
debugPrint(' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s');
|
||||
debugPrint(
|
||||
' Automatic broadcasts will resume after ${minTimeIntervalSeconds}s',
|
||||
);
|
||||
onBroadcastSent?.call(position);
|
||||
|
||||
return true;
|
||||
@@ -480,12 +650,24 @@ class LocationTrackingService {
|
||||
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
|
||||
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
|
||||
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
|
||||
fastLocationUpdatesEnabled =
|
||||
prefs.getBool(_prefKeyFastLocationEnabled) ?? false;
|
||||
fastLocationMovementThresholdMeters =
|
||||
(prefs.getDouble(_prefKeyFastMovementThreshold) ?? gpsUpdateDistance)
|
||||
.clamp(1.0, 1000.0);
|
||||
fastLocationActiveCadenceSeconds =
|
||||
(prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Settings loaded');
|
||||
debugPrint(' Min distance: ${minDistanceMeters}m');
|
||||
debugPrint(' Max distance: ${maxDistanceMeters}m');
|
||||
debugPrint(' Min time interval: ${minTimeIntervalSeconds}s');
|
||||
debugPrint(' GPS update distance: ${gpsUpdateDistance}m');
|
||||
debugPrint(' Fast updates enabled: $fastLocationUpdatesEnabled');
|
||||
debugPrint(
|
||||
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
|
||||
);
|
||||
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
|
||||
}
|
||||
|
||||
/// Save settings to SharedPreferences
|
||||
@@ -497,6 +679,18 @@ class LocationTrackingService {
|
||||
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
|
||||
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
|
||||
await prefs.setBool(_prefKeyEnabled, isTracking);
|
||||
await prefs.setBool(
|
||||
_prefKeyFastLocationEnabled,
|
||||
fastLocationUpdatesEnabled,
|
||||
);
|
||||
await prefs.setDouble(
|
||||
_prefKeyFastMovementThreshold,
|
||||
fastLocationMovementThresholdMeters,
|
||||
);
|
||||
await prefs.setInt(
|
||||
_prefKeyFastActiveCadence,
|
||||
fastLocationActiveCadenceSeconds,
|
||||
);
|
||||
|
||||
debugPrint('✅ [LocationTracking] Settings saved');
|
||||
}
|
||||
@@ -509,6 +703,7 @@ class LocationTrackingService {
|
||||
void dispose() {
|
||||
debugPrint('🗑️ [LocationTracking] Disposing service');
|
||||
_positionSubscription?.cancel();
|
||||
_fastLocationTimer?.cancel();
|
||||
_positionSubscription = null;
|
||||
_bleService = null;
|
||||
_isInitialized = false;
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/sar_marker.dart';
|
||||
import '../widgets/common/contact_avatar.dart';
|
||||
import '../widgets/map/location_pointer.dart';
|
||||
|
||||
/// Centralized service for map marker management.
|
||||
@@ -77,9 +78,15 @@ class MapMarkerService {
|
||||
// Marker icon
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: getContactMarkerColor(contact, context),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
color: Colors.white,
|
||||
shape: contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room
|
||||
? BoxShape.rectangle
|
||||
: BoxShape.circle,
|
||||
borderRadius: contact.type == ContactType.channel ||
|
||||
contact.type == ContactType.room
|
||||
? BorderRadius.circular(14)
|
||||
: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.3),
|
||||
@@ -88,17 +95,8 @@ class MapMarkerService {
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(6),
|
||||
child: contact.roleEmoji != null
|
||||
? Text(
|
||||
contact.roleEmoji!,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
)
|
||||
: Icon(
|
||||
getContactMarkerIcon(contact),
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: ContactAvatar(contact: contact, radius: 16),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// Name label (without emoji)
|
||||
|
||||
@@ -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 'package:http/http.dart' as http;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class MeshMapNode {
|
||||
final int type;
|
||||
@@ -33,26 +35,38 @@ class MeshMapNode {
|
||||
class MeshMapNodesService {
|
||||
static const String _nodesEndpoint =
|
||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||
static const Duration _cacheTtl = Duration(minutes: 2);
|
||||
static const Duration traceCacheTtl = Duration(minutes: 10);
|
||||
static const int repeaterType = 1;
|
||||
static const Duration _cacheTtl = Duration(hours: 24);
|
||||
static const Duration traceCacheTtl = _cacheTtl;
|
||||
static const Duration traceTimeout = Duration(seconds: 30);
|
||||
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
|
||||
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
|
||||
static List<MeshMapNode>? _cachedNodes;
|
||||
static DateTime? _cachedAt;
|
||||
static Future<void>? _ongoingSync;
|
||||
|
||||
static Future<List<MeshMapNode>> fetchNodes({
|
||||
bool forceRefresh = false,
|
||||
Duration cacheTtl = _cacheTtl,
|
||||
bool allowNetwork = true,
|
||||
http.Client? client,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||
if (!forceRefresh &&
|
||||
_cachedNodes != null &&
|
||||
cached.isNotEmpty &&
|
||||
_cachedAt != null &&
|
||||
now.difference(_cachedAt!) < cacheTtl) {
|
||||
return _cachedNodes!;
|
||||
return cached;
|
||||
}
|
||||
|
||||
final response = await http
|
||||
if (!allowNetwork) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final response = await (client ?? http.Client())
|
||||
.get(Uri.parse(_nodesEndpoint))
|
||||
.timeout(const Duration(seconds: 12));
|
||||
.timeout(traceTimeout);
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw Exception('Map nodes API returned ${response.statusCode}');
|
||||
}
|
||||
@@ -68,8 +82,133 @@ class MeshMapNodesService {
|
||||
)
|
||||
.toList();
|
||||
|
||||
_cachedNodes = nodes;
|
||||
_cachedAt = now;
|
||||
await _storeCache(nodes, cachedAt: now);
|
||||
return nodes;
|
||||
}
|
||||
|
||||
static Future<List<MeshMapNode>> loadCachedNodes({
|
||||
Duration cacheTtl = _cacheTtl,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
if (_cachedNodes != null &&
|
||||
_cachedAt != null &&
|
||||
now.difference(_cachedAt!) < cacheTtl) {
|
||||
return _cachedNodes!;
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||
final cachedJson = prefs.getString(_cacheKey);
|
||||
if (cachedAtMs == null || cachedJson == null) {
|
||||
_cachedNodes = null;
|
||||
_cachedAt = null;
|
||||
return const [];
|
||||
}
|
||||
|
||||
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||
if (now.difference(cachedAt) >= cacheTtl) {
|
||||
_cachedNodes = null;
|
||||
_cachedAt = cachedAt;
|
||||
return const [];
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(cachedJson) as List<dynamic>;
|
||||
final nodes = decoded
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(MeshMapNode.fromJson)
|
||||
.where(
|
||||
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
|
||||
)
|
||||
.toList();
|
||||
_cachedNodes = nodes;
|
||||
_cachedAt = cachedAt;
|
||||
return nodes;
|
||||
}
|
||||
|
||||
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
|
||||
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||
return nodes.isNotEmpty;
|
||||
}
|
||||
|
||||
static Future<void> syncInBackgroundIfStale({
|
||||
Duration cacheTtl = _cacheTtl,
|
||||
http.Client? client,
|
||||
}) async {
|
||||
final now = DateTime.now();
|
||||
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
|
||||
return;
|
||||
}
|
||||
|
||||
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||
if (cached.isNotEmpty && _cachedAt != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_ongoingSync != null) {
|
||||
return _ongoingSync!;
|
||||
}
|
||||
|
||||
_ongoingSync = () async {
|
||||
try {
|
||||
await fetchNodes(
|
||||
forceRefresh: true,
|
||||
cacheTtl: cacheTtl,
|
||||
allowNetwork: true,
|
||||
client: client,
|
||||
);
|
||||
} catch (_) {
|
||||
// Background refresh is best-effort only.
|
||||
} finally {
|
||||
_ongoingSync = null;
|
||||
}
|
||||
}();
|
||||
|
||||
return _ongoingSync!;
|
||||
}
|
||||
|
||||
static Future<void> clearCache() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_cacheKey);
|
||||
await prefs.remove(_cacheTimestampKey);
|
||||
_cachedNodes = null;
|
||||
_cachedAt = null;
|
||||
}
|
||||
|
||||
static Future<DateTime?> cachedAt() async {
|
||||
if (_cachedAt != null) return _cachedAt;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||
if (cachedAtMs == null) return null;
|
||||
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||
return _cachedAt;
|
||||
}
|
||||
|
||||
static bool isRepeater(MeshMapNode node) => node.type == repeaterType;
|
||||
|
||||
static Future<void> _storeCache(
|
||||
List<MeshMapNode> nodes, {
|
||||
required DateTime cachedAt,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(
|
||||
_cacheKey,
|
||||
jsonEncode(
|
||||
nodes
|
||||
.map(
|
||||
(node) => {
|
||||
'type': node.type,
|
||||
'name': node.name,
|
||||
'public_key': node.publicKey,
|
||||
'latitude': node.latitude,
|
||||
'longitude': node.longitude,
|
||||
'updated_at': node.updatedAtMs,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch);
|
||||
_cachedNodes = nodes;
|
||||
_cachedAt = cachedAt;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ class MessageDestinationPreferences {
|
||||
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
|
||||
|
||||
/// Destination types
|
||||
static const String destinationTypeAll = 'all';
|
||||
static const String destinationTypeChannel = 'channel';
|
||||
static const String destinationTypeContact = 'contact';
|
||||
static const String destinationTypeRoom = 'room';
|
||||
@@ -24,14 +25,11 @@ class MessageDestinationPreferences {
|
||||
|
||||
final publicKey = prefs.getString(_recipientPublicKeyKey);
|
||||
|
||||
return {
|
||||
'type': type,
|
||||
'publicKey': ?publicKey,
|
||||
};
|
||||
return {'type': type, 'publicKey': ?publicKey};
|
||||
}
|
||||
|
||||
/// Save the selected destination
|
||||
/// [type] - one of: destinationTypeChannel, destinationTypeContact, destinationTypeRoom
|
||||
/// [type] - one of: destinationTypeAll, destinationTypeChannel, destinationTypeContact, destinationTypeRoom
|
||||
/// [recipientPublicKey] - hex string of recipient's public key (required for contact/room)
|
||||
static Future<void> setDestination(
|
||||
String type, {
|
||||
@@ -58,6 +56,8 @@ class MessageDestinationPreferences {
|
||||
/// Get display name for destination type
|
||||
static String getDestinationTypeName(String type) {
|
||||
switch (type) {
|
||||
case destinationTypeAll:
|
||||
return 'All';
|
||||
case destinationTypeChannel:
|
||||
return 'Channel';
|
||||
case destinationTypeContact:
|
||||
|
||||
@@ -3,25 +3,48 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/message.dart';
|
||||
import '../models/message_contact_location.dart';
|
||||
import '../models/message_reception_details.dart';
|
||||
import '../models/message_transfer_details.dart';
|
||||
import '../models/message_route_metadata.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
/// Service for persisting messages to local storage
|
||||
class MessageStorageService {
|
||||
static const String _messagesKey = 'stored_messages';
|
||||
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
|
||||
static const String _messageContactLocationsKey =
|
||||
'stored_message_contact_locations';
|
||||
static const String _messageReceptionDetailsKey =
|
||||
'stored_message_reception_details';
|
||||
static const String _messageTransferDetailsKey =
|
||||
'stored_message_transfer_details';
|
||||
static const String _messageRouteMetadataKey =
|
||||
'stored_message_route_metadata';
|
||||
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
|
||||
static const String _legacyPathBytesKey = 'storedPathBytes';
|
||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||
|
||||
/// Save messages to persistent storage
|
||||
Future<void> saveMessages(
|
||||
List<Message> messages, {
|
||||
Map<String, MessageContactLocation> messageContactLocations = const {},
|
||||
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
||||
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
||||
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
|
||||
}) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// Convert messages to JSON
|
||||
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
||||
// Convert messages to JSON and embed path bytes as a fallback so they
|
||||
// survive restore even if the sidecar reception-details entry is absent.
|
||||
final jsonList = messages
|
||||
.map(
|
||||
(msg) => _messageToJson(
|
||||
msg,
|
||||
receptionDetails: messageReceptionDetails[msg.id],
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// Limit to max stored messages (keep most recent)
|
||||
final limitedList = jsonList.length > _maxStoredMessages
|
||||
@@ -34,15 +57,45 @@ class MessageStorageService {
|
||||
.map((entry) => entry['id'] as String)
|
||||
.toSet();
|
||||
final locationJson = <String, dynamic>{};
|
||||
final receptionJson = <String, dynamic>{};
|
||||
final transferJson = <String, dynamic>{};
|
||||
final routeMetadataJson = <String, dynamic>{};
|
||||
for (final entry in messageContactLocations.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
locationJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
for (final entry in messageReceptionDetails.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
receptionJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
for (final entry in messageTransferDetails.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
transferJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
for (final entry in messageRouteMetadata.entries) {
|
||||
if (retainedMessageIds.contains(entry.key)) {
|
||||
routeMetadataJson[entry.key] = entry.value.toJson();
|
||||
}
|
||||
}
|
||||
await prefs.setString(
|
||||
_messageContactLocationsKey,
|
||||
jsonEncode(locationJson),
|
||||
);
|
||||
await prefs.setString(
|
||||
_messageReceptionDetailsKey,
|
||||
jsonEncode(receptionJson),
|
||||
);
|
||||
await prefs.setString(
|
||||
_messageTransferDetailsKey,
|
||||
jsonEncode(transferJson),
|
||||
);
|
||||
await prefs.setString(
|
||||
_messageRouteMetadataKey,
|
||||
jsonEncode(routeMetadataJson),
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',
|
||||
@@ -52,8 +105,8 @@ class MessageStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageContactLocation>> loadMessageContactLocations()
|
||||
async {
|
||||
Future<Map<String, MessageContactLocation>>
|
||||
loadMessageContactLocations() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messageContactLocationsKey);
|
||||
@@ -82,6 +135,104 @@ class MessageStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageReceptionDetails>>
|
||||
loadMessageReceptionDetails() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messageReceptionDetailsKey);
|
||||
final result = <String, MessageReceptionDetails>{};
|
||||
if (jsonString != null && jsonString.isNotEmpty) {
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
final snapshot = MessageReceptionDetails.fromJson(value);
|
||||
if (snapshot != null) {
|
||||
result[entry.key] = snapshot;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails();
|
||||
embeddedReceptionDetails.forEach((messageId, snapshot) {
|
||||
result.putIfAbsent(messageId, () => snapshot);
|
||||
});
|
||||
|
||||
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages();
|
||||
fallbackPathBytes.forEach((messageId, pathBytes) {
|
||||
result.putIfAbsent(
|
||||
messageId,
|
||||
() => MessageReceptionDetails(
|
||||
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
pathBytes: pathBytes,
|
||||
),
|
||||
);
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageTransferDetails>>
|
||||
loadMessageTransferDetails() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messageTransferDetailsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, MessageTransferDetails>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
final details = MessageTransferDetails.fromJson(value);
|
||||
if (details != null) {
|
||||
result[entry.key] = details;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading transfer details: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messageRouteMetadataKey);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, MessageRouteMetadata>{};
|
||||
for (final entry in decoded.entries) {
|
||||
final value = entry.value;
|
||||
if (value is! Map<String, dynamic>) continue;
|
||||
result[entry.key] = MessageRouteMetadata.fromJson(value);
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading route metadata: $e');
|
||||
return const {};
|
||||
}
|
||||
}
|
||||
|
||||
/// Load messages from persistent storage
|
||||
Future<List<Message>> loadMessages() async {
|
||||
try {
|
||||
@@ -116,12 +267,39 @@ class MessageStorageService {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_messagesKey);
|
||||
await prefs.remove(_messageContactLocationsKey);
|
||||
await prefs.remove(_messageReceptionDetailsKey);
|
||||
await prefs.remove(_messageTransferDetailsKey);
|
||||
await prefs.remove(_messageRouteMetadataKey);
|
||||
await prefs.remove(_removedSarMarkerIdsKey);
|
||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<String>> loadRemovedSarMarkerIds() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final ids = prefs.getStringList(_removedSarMarkerIdsKey) ?? const [];
|
||||
return ids.toSet();
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
|
||||
return const <String>{};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveRemovedSarMarkerIds(Set<String> ids) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_removedSarMarkerIdsKey, ids.toList()..sort());
|
||||
debugPrint(
|
||||
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get storage statistics
|
||||
Future<Map<String, dynamic>> getStorageStats() async {
|
||||
try {
|
||||
@@ -147,7 +325,12 @@ class MessageStorageService {
|
||||
}
|
||||
|
||||
/// Convert Message to JSON
|
||||
Map<String, dynamic> _messageToJson(Message message) {
|
||||
Map<String, dynamic> _messageToJson(
|
||||
Message message, {
|
||||
MessageReceptionDetails? receptionDetails,
|
||||
}) {
|
||||
final persistedPathBytes =
|
||||
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
|
||||
return {
|
||||
'id': message.id,
|
||||
'messageType': message.messageType.name,
|
||||
@@ -156,6 +339,7 @@ class MessageStorageService {
|
||||
: null,
|
||||
'channelIdx': message.channelIdx,
|
||||
'pathLen': message.pathLen,
|
||||
'pathBytes': persistedPathBytes?.toList(),
|
||||
'textType': message.textType.value,
|
||||
'senderTimestamp': message.senderTimestamp,
|
||||
'text': message.text,
|
||||
@@ -207,9 +391,67 @@ class MessageStorageService {
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
if (receptionDetails != null)
|
||||
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
|
||||
if (receptionDetails?.pathBytes case final pathBytes?)
|
||||
_legacyPathBytesKey: List<int>.from(pathBytes),
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, MessageReceptionDetails>>
|
||||
_loadEmbeddedReceptionDetails() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messagesKey);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! List) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, MessageReceptionDetails>{};
|
||||
for (final entry in decoded) {
|
||||
if (entry is! Map<String, dynamic>) continue;
|
||||
final messageId = entry['id'];
|
||||
final embedded = entry[_embeddedReceptionDetailsKey];
|
||||
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
|
||||
final snapshot = MessageReceptionDetails.fromJson(embedded);
|
||||
if (snapshot == null) continue;
|
||||
result[messageId] = snapshot;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonString = prefs.getString(_messagesKey);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! List) {
|
||||
return const {};
|
||||
}
|
||||
|
||||
final result = <String, List<int>>{};
|
||||
for (final entry in decoded) {
|
||||
if (entry is! Map<String, dynamic>) continue;
|
||||
final messageId = entry['id'];
|
||||
final pathBytes = entry[_legacyPathBytesKey];
|
||||
if (messageId is! String || pathBytes is! List) continue;
|
||||
final normalized = pathBytes
|
||||
.whereType<num>()
|
||||
.map((b) => b.toInt())
|
||||
.toList();
|
||||
if (normalized.isEmpty) continue;
|
||||
result[messageId] = normalized;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Convert JSON to Message
|
||||
Message? _messageFromJson(Map<String, dynamic> json) {
|
||||
try {
|
||||
@@ -226,6 +468,14 @@ class MessageStorageService {
|
||||
: null,
|
||||
channelIdx: json['channelIdx'] as int?,
|
||||
pathLen: json['pathLen'] as int,
|
||||
pathBytes: json['pathBytes'] is List
|
||||
? Uint8List.fromList(
|
||||
(json['pathBytes'] as List<dynamic>)
|
||||
.whereType<num>()
|
||||
.map((b) => b.toInt())
|
||||
.toList(),
|
||||
)
|
||||
: null,
|
||||
textType: MessageTextType.fromValue(json['textType'] as int),
|
||||
senderTimestamp: json['senderTimestamp'] as int,
|
||||
text: json['text'] as String,
|
||||
@@ -323,4 +573,12 @@ class MessageStorageService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
|
||||
final pathBytes = receptionDetails?.pathBytes;
|
||||
if (pathBytes == null || pathBytes.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return Uint8List.fromList(pathBytes);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import '../models/sar_marker.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
@@ -13,14 +14,26 @@ class NotificationService {
|
||||
|
||||
final FlutterLocalNotificationsPlugin _notificationsPlugin =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
static const String _prefMessagesEnabled = 'notifications_messages_enabled';
|
||||
static const String _prefSarEnabled = 'notifications_sar_enabled';
|
||||
static const String _prefUpdatesEnabled = 'notifications_updates_enabled';
|
||||
static const String _prefMuteForeground = 'notifications_mute_foreground';
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool _permissionGranted = false;
|
||||
bool _messageNotificationsEnabled = true;
|
||||
bool _sarNotificationsEnabled = true;
|
||||
bool _updateNotificationsEnabled = true;
|
||||
bool _muteForegroundNotifications = true;
|
||||
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||
String? _launchPayload;
|
||||
|
||||
// Notification IDs
|
||||
static const int _sarNotificationId = 1000;
|
||||
static const int _messageNotificationId = 2000;
|
||||
static const int _updateNotificationId = 3000;
|
||||
static const int _batteryNotificationId = 4000;
|
||||
static const int _discoveryNotificationId = 5000;
|
||||
|
||||
// Notification channels
|
||||
static const String _urgentChannelId = 'sar_urgent';
|
||||
@@ -38,12 +51,31 @@ class NotificationService {
|
||||
static const String _updateChannelDescription =
|
||||
'Notifications for available app updates';
|
||||
|
||||
static const String _batteryChannelId = 'battery_alerts';
|
||||
static const String _batteryChannelName = 'Battery Alerts';
|
||||
static const String _batteryChannelDescription =
|
||||
'Notifications when a device or contact battery is low';
|
||||
|
||||
static const String _discoveryChannelId = 'discovery_alerts';
|
||||
static const String _discoveryChannelName = 'Discovery Alerts';
|
||||
static const String _discoveryChannelDescription =
|
||||
'Notifications when new contacts are discovered';
|
||||
|
||||
bool get messageNotificationsEnabled => _messageNotificationsEnabled;
|
||||
bool get sarNotificationsEnabled => _sarNotificationsEnabled;
|
||||
bool get updateNotificationsEnabled => _updateNotificationsEnabled;
|
||||
bool get muteForegroundNotifications => _muteForegroundNotifications;
|
||||
bool get isAppInForeground => _lifecycleState == AppLifecycleState.resumed;
|
||||
|
||||
/// Initialize notification service
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
try {
|
||||
debugPrint('📬 [NotificationService] Initializing...');
|
||||
WidgetsBinding.instance.addObserver(_LifecycleObserver(this));
|
||||
_lifecycleState =
|
||||
WidgetsBinding.instance.lifecycleState ?? AppLifecycleState.resumed;
|
||||
|
||||
// Initialize timezone data
|
||||
tz.initializeTimeZones();
|
||||
@@ -58,7 +90,6 @@ class NotificationService {
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
requestCriticalPermission: true, // For urgent SAR notifications
|
||||
);
|
||||
|
||||
// Combined initialization settings
|
||||
@@ -73,8 +104,15 @@ class NotificationService {
|
||||
onDidReceiveNotificationResponse: _onNotificationResponse,
|
||||
);
|
||||
|
||||
final launchDetails = await _notificationsPlugin
|
||||
.getNotificationAppLaunchDetails();
|
||||
if (launchDetails?.didNotificationLaunchApp ?? false) {
|
||||
_launchPayload = launchDetails?.notificationResponse?.payload;
|
||||
}
|
||||
|
||||
// Request permissions
|
||||
await _requestPermissions();
|
||||
await _loadPreferences();
|
||||
|
||||
// Create notification channels (Android)
|
||||
await _createNotificationChannels();
|
||||
@@ -100,8 +138,6 @@ class NotificationService {
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
critical:
|
||||
true, // Request critical alert permission for urgent SAR notifications
|
||||
);
|
||||
_permissionGranted = granted ?? false;
|
||||
debugPrint(
|
||||
@@ -135,6 +171,46 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadPreferences() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_messageNotificationsEnabled = prefs.getBool(_prefMessagesEnabled) ?? true;
|
||||
_sarNotificationsEnabled = prefs.getBool(_prefSarEnabled) ?? true;
|
||||
_updateNotificationsEnabled = prefs.getBool(_prefUpdatesEnabled) ?? true;
|
||||
_muteForegroundNotifications = prefs.getBool(_prefMuteForeground) ?? true;
|
||||
}
|
||||
|
||||
Future<void> setMessageNotificationsEnabled(bool value) async {
|
||||
_messageNotificationsEnabled = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefMessagesEnabled, value);
|
||||
}
|
||||
|
||||
Future<void> setSarNotificationsEnabled(bool value) async {
|
||||
_sarNotificationsEnabled = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefSarEnabled, value);
|
||||
}
|
||||
|
||||
Future<void> setUpdateNotificationsEnabled(bool value) async {
|
||||
_updateNotificationsEnabled = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefUpdatesEnabled, value);
|
||||
}
|
||||
|
||||
Future<void> setMuteForegroundNotifications(bool value) async {
|
||||
_muteForegroundNotifications = value;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_prefMuteForeground, value);
|
||||
}
|
||||
|
||||
void handleAppLifecycleStateChanged(AppLifecycleState state) {
|
||||
_lifecycleState = state;
|
||||
}
|
||||
|
||||
bool _shouldSuppressForegroundNotifications() {
|
||||
return _muteForegroundNotifications && isAppInForeground;
|
||||
}
|
||||
|
||||
/// Create notification channels for Android
|
||||
Future<void> _createNotificationChannels() async {
|
||||
try {
|
||||
@@ -180,9 +256,31 @@ class NotificationService {
|
||||
showBadge: true,
|
||||
);
|
||||
|
||||
const batteryChannel = AndroidNotificationChannel(
|
||||
_batteryChannelId,
|
||||
_batteryChannelName,
|
||||
description: _batteryChannelDescription,
|
||||
importance: Importance.high,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
showBadge: true,
|
||||
);
|
||||
|
||||
const discoveryChannel = AndroidNotificationChannel(
|
||||
_discoveryChannelId,
|
||||
_discoveryChannelName,
|
||||
description: _discoveryChannelDescription,
|
||||
importance: Importance.high,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
showBadge: true,
|
||||
);
|
||||
|
||||
await androidPlugin.createNotificationChannel(urgentChannel);
|
||||
await androidPlugin.createNotificationChannel(messagesChannel);
|
||||
await androidPlugin.createNotificationChannel(updateChannel);
|
||||
await androidPlugin.createNotificationChannel(batteryChannel);
|
||||
await androidPlugin.createNotificationChannel(discoveryChannel);
|
||||
debugPrint('✅ [NotificationService] Created notification channels');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [NotificationService] Error creating channels: $e');
|
||||
@@ -204,6 +302,12 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
String? consumeLaunchPayload() {
|
||||
final payload = _launchPayload;
|
||||
_launchPayload = null;
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// Show urgent notification for SAR marker
|
||||
Future<void> showSarNotification({
|
||||
required SarMarkerType type,
|
||||
@@ -225,6 +329,16 @@ class NotificationService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!_sarNotificationsEnabled) {
|
||||
debugPrint('ℹ️ [NotificationService] SAR notifications disabled');
|
||||
return;
|
||||
}
|
||||
if (_shouldSuppressForegroundNotifications()) {
|
||||
debugPrint(
|
||||
'ℹ️ [NotificationService] App in foreground, skipping SAR notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate unique notification ID based on timestamp
|
||||
@@ -274,8 +388,7 @@ class NotificationService {
|
||||
badgeNumber: 1,
|
||||
threadIdentifier: 'sar_markers',
|
||||
categoryIdentifier: 'SAR_ALERT',
|
||||
interruptionLevel:
|
||||
InterruptionLevel.critical, // Critical alert (bypasses silent mode)
|
||||
interruptionLevel: InterruptionLevel.timeSensitive,
|
||||
);
|
||||
|
||||
// Combined notification details
|
||||
@@ -398,6 +511,16 @@ class NotificationService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!_messageNotificationsEnabled) {
|
||||
debugPrint('ℹ️ [NotificationService] Message notifications disabled');
|
||||
return;
|
||||
}
|
||||
if (_shouldSuppressForegroundNotifications()) {
|
||||
debugPrint(
|
||||
'ℹ️ [NotificationService] App in foreground, skipping message notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate unique notification ID based on timestamp
|
||||
@@ -406,10 +529,13 @@ class NotificationService {
|
||||
(DateTime.now().millisecondsSinceEpoch % 1000);
|
||||
|
||||
// Build notification title and body
|
||||
final resolvedChannelName = channelName?.trim().isNotEmpty == true
|
||||
? channelName!.trim()
|
||||
: (localizations?.publicChannel ?? 'Public');
|
||||
final title = isChannelMessage
|
||||
? (localizations != null
|
||||
? '${localizations.channel}: ${channelName ?? "Public"}'
|
||||
: 'Channel: ${channelName ?? "Public"}')
|
||||
? '${localizations.channel}: $resolvedChannelName'
|
||||
: 'Channel: $resolvedChannelName')
|
||||
: (localizations != null
|
||||
? '${localizations.newMessage} ${localizations.from} $senderName'
|
||||
: 'New message from $senderName');
|
||||
@@ -548,13 +674,23 @@ class NotificationService {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!_updateNotificationsEnabled) {
|
||||
debugPrint('ℹ️ [NotificationService] Update notifications disabled');
|
||||
return;
|
||||
}
|
||||
if (_shouldSuppressForegroundNotifications()) {
|
||||
debugPrint(
|
||||
'ℹ️ [NotificationService] App in foreground, skipping update notification',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build notification title and body
|
||||
final title = localizations?.updateAvailable ?? 'App Update Available';
|
||||
final body = localizations != null
|
||||
? '${localizations.currentVersion}: $currentVersion\n'
|
||||
'${localizations.latestVersion}: $latestVersion'
|
||||
'${localizations.latestVersion}: $latestVersion'
|
||||
: 'Current: $currentVersion\nLatest: $latestVersion';
|
||||
|
||||
// Android notification details
|
||||
@@ -618,4 +754,169 @@ class NotificationService {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> showLowBatteryNotification({
|
||||
required String nodeId,
|
||||
required String nodeName,
|
||||
required double batteryPercent,
|
||||
required bool isCurrentDevice,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
debugPrint(
|
||||
'⚠️ [NotificationService] Not initialized, skipping notification',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_permissionGranted) {
|
||||
debugPrint(
|
||||
'⚠️ [NotificationService] Permission not granted, skipping notification',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!_messageNotificationsEnabled) {
|
||||
debugPrint('ℹ️ [NotificationService] Message notifications disabled');
|
||||
return false;
|
||||
}
|
||||
if (_shouldSuppressForegroundNotifications()) {
|
||||
debugPrint(
|
||||
'ℹ️ [NotificationService] App in foreground, skipping low battery notification',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
final roundedPercent = batteryPercent.round().clamp(0, 100);
|
||||
final title = isCurrentDevice
|
||||
? 'Device battery low'
|
||||
: 'Contact battery low';
|
||||
final body = isCurrentDevice
|
||||
? '$nodeName battery is at $roundedPercent%.'
|
||||
: '$nodeName is at $roundedPercent% battery.';
|
||||
final notificationId =
|
||||
_batteryNotificationId + ((nodeId.hashCode & 0x7fffffff) % 1000);
|
||||
|
||||
try {
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
_batteryChannelId,
|
||||
_batteryChannelName,
|
||||
channelDescription: _batteryChannelDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
ticker: title,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
showWhen: true,
|
||||
when: DateTime.now().millisecondsSinceEpoch,
|
||||
styleInformation: BigTextStyleInformation(
|
||||
body,
|
||||
contentTitle: title,
|
||||
summaryText: '$roundedPercent%',
|
||||
),
|
||||
);
|
||||
|
||||
final darwinDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
sound: 'default',
|
||||
threadIdentifier: 'battery_alerts',
|
||||
subtitle: '$roundedPercent%',
|
||||
);
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: darwinDetails,
|
||||
);
|
||||
|
||||
await _notificationsPlugin.show(
|
||||
id: notificationId,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: notificationDetails,
|
||||
payload: 'battery:$nodeId',
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'✅ [NotificationService] Showed low battery notification for $nodeName ($roundedPercent%)',
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'❌ [NotificationService] Error showing low battery notification: $e',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> showContactDiscoveredNotification({
|
||||
required String contactKey,
|
||||
String? contactName,
|
||||
}) async {
|
||||
if (!_isInitialized) return false;
|
||||
if (!_permissionGranted) return false;
|
||||
if (!_messageNotificationsEnabled) return false;
|
||||
if (_shouldSuppressForegroundNotifications()) return false;
|
||||
|
||||
final shortKey = contactKey.length > 12
|
||||
? contactKey.substring(0, 12).toUpperCase()
|
||||
: contactKey.toUpperCase();
|
||||
final title = 'New contact discovered';
|
||||
final resolvedName = contactName?.trim();
|
||||
final body = resolvedName != null && resolvedName.isNotEmpty
|
||||
? '$resolvedName is available in Discovery.'
|
||||
: 'New contact $shortKey is available in Discovery.';
|
||||
final notificationId =
|
||||
_discoveryNotificationId + ((contactKey.hashCode & 0x7fffffff) % 1000);
|
||||
|
||||
try {
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
_discoveryChannelId,
|
||||
_discoveryChannelName,
|
||||
channelDescription: _discoveryChannelDescription,
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
ticker: title,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
showWhen: true,
|
||||
when: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
final darwinDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
sound: 'default',
|
||||
threadIdentifier: 'discovery_alerts',
|
||||
);
|
||||
|
||||
await _notificationsPlugin.show(
|
||||
id: notificationId,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails: NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: darwinDetails,
|
||||
),
|
||||
payload: 'discovery:$contactKey',
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'❌ [NotificationService] Error showing discovery notification: $e',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LifecycleObserver with WidgetsBindingObserver {
|
||||
_LifecycleObserver(this._service);
|
||||
|
||||
final NotificationService _service;
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
_service.handleAppLifecycleStateChanged(state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ class PacketCaptureStorageService {
|
||||
File? _file;
|
||||
|
||||
Future<File> _resolveFile() async {
|
||||
if (kIsWeb) {
|
||||
throw UnsupportedError('Packet capture file storage is not supported on web');
|
||||
}
|
||||
if (_file != null) return _file!;
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
final file = File('${dir.path}/$_fileName');
|
||||
@@ -73,6 +76,7 @@ class PacketCaptureStorageService {
|
||||
|
||||
Future<void> appendLogs(List<BlePacketLog> logs) async {
|
||||
if (logs.isEmpty) return;
|
||||
if (kIsWeb) return;
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
final sink = file.openWrite(mode: FileMode.append);
|
||||
@@ -98,6 +102,7 @@ class PacketCaptureStorageService {
|
||||
}
|
||||
|
||||
Future<List<StoredPacketCapture>> loadRecent({int limit = 500}) async {
|
||||
if (kIsWeb) return const [];
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
if (!await file.exists()) return const [];
|
||||
@@ -116,6 +121,7 @@ class PacketCaptureStorageService {
|
||||
}
|
||||
|
||||
Future<int> count() async {
|
||||
if (kIsWeb) return 0;
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
if (!await file.exists()) return 0;
|
||||
@@ -127,6 +133,7 @@ class PacketCaptureStorageService {
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
if (kIsWeb) return;
|
||||
try {
|
||||
final file = await _resolveFile();
|
||||
if (await file.exists()) {
|
||||
|
||||
282
lib/services/path_history_service.dart
Normal file
282
lib/services/path_history_service.dart
Normal file
@@ -0,0 +1,282 @@
|
||||
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';
|
||||
import '../utils/log_rx_route_decoder.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,
|
||||
source: existing?.source ?? PathRecordSource.learned,
|
||||
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 normalizedPathBytes = LogRxRouteDecoder.reverseHopBytes(
|
||||
pathBytes,
|
||||
hashSize: hashSize,
|
||||
);
|
||||
|
||||
final history = _historyFor(contactPublicKeyHex);
|
||||
final signature = normalizedPathBytes
|
||||
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||
.join();
|
||||
final existing = _findDirectPath(history.directPaths, signature);
|
||||
final updated = PathRecord(
|
||||
pathBytes: normalizedPathBytes,
|
||||
hopCount: normalizedPathBytes.length ~/ hashSize,
|
||||
hashSize: hashSize,
|
||||
source: PathRecordSource.observed,
|
||||
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,
|
||||
source: existing?.source ?? PathRecordSource.learned,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user