Compare commits

...

55 Commits

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

View File

@@ -12,7 +12,7 @@ permissions:
id-token: write
env:
FLUTTER_VERSION: "3.35.6"
FLUTTER_VERSION: "3.41.4"
APP_NAME: meshcore-sar
ANDROID_NDK_VERSION: "27.0.12077973"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -489,7 +489,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 84;
CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -511,7 +511,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 84;
CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -530,7 +530,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 84;
CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -547,7 +547,7 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 84;
CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@@ -679,7 +679,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 84;
CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -702,7 +702,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = 84;
CURRENT_PROJECT_VERSION = 105;
DEVELOPMENT_TEAM = JND55328G8;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -43,9 +43,11 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>84</string>
<string>105</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search &amp; Rescue operations</string>
<key>NSBluetoothPeripheralUsageDescription</key>

View File

@@ -5,22 +5,24 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000211">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000243">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.408783">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.511584">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="120.492323">
<testcase classname="fastlane.lanes" name="2: build_app" time="95.467725">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="12.544865">
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="240.60994">
<failure message="/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in &apos;Fastlane::Actions.execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:255:in &apos;block in Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in &apos;Fastlane::Runner#execute_action&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:157:in &apos;Fastlane::Runner#trigger_action_by_name&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/fast_file.rb:159:in &apos;Fastlane::FastFile#method_missing&apos;&#10;Fastfile:23:in &apos;block (2 levels) in Fastlane::FastFile#parsing_binding&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane.rb:41:in &apos;Fastlane::Lane#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:49:in &apos;block in Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in &apos;Dir.chdir&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in &apos;Fastlane::Runner#execute&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane_manager.rb:46:in &apos;Fastlane::LaneManager.cruise_lane&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/command_line_handler.rb:34:in &apos;Fastlane::CommandLineHandler.handle&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:110:in &apos;block (2 levels) in Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in &apos;Commander::Command#call&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in &apos;Commander::Command#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in &apos;Commander::Runner#run_active_command&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in &apos;Commander::Runner#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in &apos;Commander::Delegates#run!&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:363:in &apos;Fastlane::CommandsGenerator#run&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:43:in &apos;Fastlane::CommandsGenerator.start&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/cli_tools_distributor.rb:132:in &apos;Fastlane::CLIToolsDistributor.take_off&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/bin/fastlane:23:in &apos;&lt;top (required)&gt;&apos;&#10;/opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in &apos;Kernel#load&apos;&#10;/opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in &apos;Gem.activate_and_load_bin_path&apos;&#10;/opt/homebrew/Cellar/fastlane/2.231.1/libexec/bin/fastlane:25:in &apos;&lt;main&gt;&apos;&#10;&#10;Error uploading ipa file: &#10; [Application Loader Error Output]: [ContentDelivery.Uploader.104B12FA0]
&#10;[Application Loader Error Output]: [altool.104B12FA0] Validation failed (409) Invalid executable. The “Runner.app/Frameworks/objective_c.framework/objective_c” executable references an unsupported platform in the arm64 slice. Since your app targets the iOS platform, it should only include executables that support the iOS platform. Simulator platforms arent permitted. (ID: 678497bb-0ad4-4cd7-b636-f9aef1caa269)
" />

View File

@@ -12,6 +12,7 @@ import 'app_localizations_es.dart';
import 'app_localizations_fr.dart';
import 'app_localizations_hr.dart';
import 'app_localizations_it.dart';
import 'app_localizations_ru.dart';
import 'app_localizations_sl.dart';
import 'app_localizations_zh.dart';
@@ -108,6 +109,7 @@ abstract class AppLocalizations {
Locale('fr'),
Locale('hr'),
Locale('it'),
Locale('ru'),
Locale('sl'),
Locale('zh'),
];
@@ -4226,6 +4228,7 @@ class _AppLocalizationsDelegate
'fr',
'hr',
'it',
'ru',
'sl',
'zh',
].contains(locale.languageCode);
@@ -4251,6 +4254,8 @@ AppLocalizations lookupAppLocalizations(Locale locale) {
return AppLocalizationsHr();
case 'it':
return AppLocalizationsIt();
case 'ru':
return AppLocalizationsRu();
case 'sl':
return AppLocalizationsSl();
case 'zh':

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,685 @@
{
"@@locale": "ru",
"appTitle": "MeshCore SAR",
"messages": "Сообщения",
"contacts": "Контакты",
"map": "Карта",
"settings": "Настройки",
"connect": "Подключить",
"disconnect": "Отключить",
"scanningForDevices": "Поиск устройств...",
"noDevicesFound": "Устройства не найдены",
"scanAgain": "Повторить поиск",
"tapToConnect": "Нажмите для подключения",
"deviceNotConnected": "Устройство не подключено",
"locationPermissionDenied": "Доступ к геолокации запрещён",
"locationPermissionPermanentlyDenied": "Доступ к геолокации запрещён навсегда. Включите его в настройках.",
"locationPermissionRequired": "Доступ к геолокации необходим для GPS-трекинга и координации команды. Вы можете включить его позже в настройках.",
"locationServicesDisabled": "Службы геолокации отключены. Пожалуйста, включите их в настройках.",
"failedToGetGpsLocation": "Не удалось получить GPS-координаты",
"advertisedAtLocation": "Транслируется в {latitude}, {longitude}",
"failedToAdvertise": "Ошибка трансляции: {error}",
"reconnecting": "Переподключение... ({attempt}/{max})",
"cancelReconnection": "Отменить переподключение",
"mapManagement": "Управление картой",
"general": "Основные",
"theme": "Тема",
"chooseTheme": "Выбрать тему",
"light": "Светлая",
"dark": "Тёмная",
"blueLightTheme": "Синяя светлая тема",
"blueDarkTheme": "Синяя тёмная тема",
"sarRed": "SAR Красная",
"alertEmergencyMode": "Режим тревоги / ЧС",
"sarGreen": "SAR Зелёная",
"safeAllClearMode": "Режим «Всё в порядке»",
"autoSystem": "Авто (Система)",
"followSystemTheme": "Следовать системной теме",
"showRxTxIndicators": "Показывать индикаторы RX/TX",
"displayPacketActivity": "Отображать активность пакетов в верхней панели",
"simpleMode": "Простой режим",
"simpleModeDescription": "Скрыть второстепенную информацию в сообщениях и контактах",
"disableMap": "Отключить карту",
"disableMapDescription": "Скрыть вкладку карты для экономии заряда батареи",
"language": "Язык",
"chooseLanguage": "Выбрать язык",
"english": "Английский",
"slovenian": "Словенский",
"croatian": "Хорватский",
"german": "Немецкий",
"spanish": "Испанский",
"french": "Французский",
"italian": "Итальянский",
"russian": "Русский",
"locationBroadcasting": "Трансляция местоположения",
"autoLocationTracking": "Авто-отслеживание геолокации",
"automaticallyBroadcastPosition": "Автоматически транслировать обновления позиции",
"configureTracking": "Настроить отслеживание",
"distanceAndTimeThresholds": "Пороги расстояния и времени",
"locationTrackingConfiguration": "Настройка отслеживания геолокации",
"configureWhenLocationBroadcasts": "Настройте условия отправки обновлений геолокации в mesh-сеть",
"minimumDistance": "Минимальное расстояние",
"broadcastAfterMoving": "Транслировать только после перемещения на {distance} м",
"maximumDistance": "Максимальное расстояние",
"alwaysBroadcastAfterMoving": "Всегда транслировать после перемещения на {distance} м",
"minimumTimeInterval": "Минимальный интервал времени",
"alwaysBroadcastEvery": "Всегда транслировать каждые {duration}",
"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": "Подробнее",
"learnMoreAbout": "Узнать больше о MeshCore SAR",
"developer": "Разработчик",
"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": "Обновить",
"sendDirectMessage": "Отправить",
"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}",
"sentDrawingsToRoom": "Отправлено {count} рисун{plural} в {roomName}",
"sharedDrawingsToRoom": "Передано {success}/{total} рисун{plural} в {roomName}",
"showReceivedDrawings": "Показать полученные рисунки",
"showingAllDrawings": "Показаны все рисунки",
"showingOnlyYourDrawings": "Показаны только ваши рисунки",
"showSarMarkers": "Показать маркеры SAR",
"showingSarMarkers": "Маркеры SAR отображаются",
"hidingSarMarkers": "Маркеры SAR скрыты",
"clearAll": "Очистить всё",
"noLocalDrawings": "Нет локальных рисунков для отправки",
"publicChannel": "Публичный канал",
"broadcastToAll": "Трансляция всем ближайшим узлам (временно)",
"storedPermanently": "Сохранено постоянно в комнате",
"drawingsSentToPublicChannel": "Отправлено {count} рисун{plural} в публичный канал",
"drawingsSharedToPublicChannel": "Передано {success}/{total} рисунков в публичный канал",
"notConnectedToDevice": "Устройство не подключено",
"directMessage": "Личное сообщение",
"directMessageSentTo": "Личное сообщение отправлено {contactName}",
"failedToSend": "Не удалось отправить: {error}",
"directMessageInfo": "Это сообщение будет отправлено напрямую {contactName}. Оно также появится в общей ленте сообщений.",
"typeYourMessage": "Введите сообщение...",
"quickLocationMarker": "Быстрый маркер местоположения",
"markerType": "Тип маркера",
"sendTo": "Отправить в",
"noDestinationsAvailable": "Нет доступных получателей.",
"selectDestination": "Выберите получателя...",
"ephemeralBroadcastInfo": "Временно: передаётся по эфиру. Не сохраняется — узлы должны быть онлайн.",
"persistentRoomInfo": "Постоянно: хранится неизменно в комнате. Синхронизируется автоматически и доступно офлайн.",
"location": "Местоположение",
"myLocation": "Моё местоположение",
"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": "Широковещательно",
"admin": "Администратор",
"loggedIn": "Вход выполнен",
"noGpsData": "Нет данных GPS",
"distance": "Расстояние",
"pingingDirect": "Пинг {name} (напрямую по маршруту)...",
"pingingFlood": "Пинг {name} (широковещательно — нет маршрута)...",
"directPingTimeout": "Таймаут прямого пинга — повтор {name} широковещательно...",
"pingSuccessful": "Пинг успешен: {name}{fallback}",
"viaFloodingFallback": " (через широковещательный резерв)",
"pingFailed": "Пинг не удался: {name} — ответ не получен",
"deleteContactConfirmation": "Вы уверены, что хотите удалить \"{name}\"?\n\nЭто удалит контакт как из приложения, так и с сопряжённого радиоустройства.",
"removingContact": "Удаление {name}...",
"contactRemoved": "Контакт \"{name}\" удалён",
"failedToRemoveContact": "Не удалось удалить контакт: {error}",
"type": "Тип",
"publicKey": "Публичный ключ",
"lastSeen": "Последнее появление",
"roomStatus": "Статус комнаты",
"loginStatus": "Статус входа",
"notLoggedIn": "Не выполнен вход",
"adminAccess": "Доступ администратора",
"yes": "Да",
"no": "Нет",
"permissions": "Разрешения",
"passwordSaved": "Пароль сохранён",
"locationColon": "Местоположение:",
"telemetry": "Телеметрия",
"requestingTelemetry": "Запрос телеметрии от {name}...",
"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": "Показать все",
"nearbyContacts": "Ближайшие контакты",
"locationUnavailable": "Местоположение недоступно",
"ahead": "впереди",
"degreesRight": "{degrees}° вправо",
"degreesLeft": "{degrees}° влево",
"latLonFormat": "Ш: {latitude} Д: {longitude}",
"noContactsYet": "Контактов пока нет",
"connectToDeviceToLoadContacts": "Подключите устройство для загрузки контактов",
"teamMembers": "Члены команды",
"repeaters": "Ретрансляторы",
"rooms": "Комнаты",
"channels": "Каналы",
"cacheStatistics": "Статистика кэша",
"totalTiles": "Всего тайлов",
"cacheSize": "Размер кэша",
"storeName": "Имя хранилища",
"noCacheStatistics": "Статистика кэша недоступна",
"downloadRegion": "Скачать регион",
"mapLayer": "Слой карты",
"regionBounds": "Границы региона",
"north": "Север",
"south": "Юг",
"east": "Восток",
"west": "Запад",
"zoomLevels": "Уровни масштаба",
"minZoom": "Мин: {zoom}",
"maxZoom": "Макс: {zoom}",
"downloadingDots": "Загрузка...",
"cancelDownload": "Отменить загрузку",
"downloadRegionButton": "Скачать регион",
"downloadNote": "Внимание: большие регионы или высокие уровни масштаба могут потребовать значительного времени и места.",
"cacheManagement": "Управление кэшем",
"clearAllMaps": "Очистить все карты",
"clearMapsConfirmTitle": "Очистить все карты",
"clearMapsConfirmMessage": "Вы уверены, что хотите удалить все загруженные карты? Это действие нельзя отменить.",
"mapDownloadCompleted": "Загрузка карты завершена!",
"cacheClearedSuccessfully": "Кэш успешно очищен!",
"downloadCancelled": "Загрузка отменена",
"startingDownload": "Начало загрузки...",
"downloadingMapTiles": "Загрузка тайлов карты...",
"downloadCompletedSuccessfully": "Загрузка успешно завершена!",
"cancellingDownload": "Отмена загрузки...",
"errorLoadingStats": "Ошибка загрузки статистики: {error}",
"downloadFailed": "Ошибка загрузки: {error}",
"cancelFailed": "Ошибка отмены: {error}",
"clearCacheFailed": "Ошибка очистки кэша: {error}",
"minZoomError": "Мин. масштаб: {error}",
"maxZoomError": "Макс. масштаб: {error}",
"minZoomGreaterThanMax": "Минимальный масштаб должен быть меньше или равен максимальному",
"selectMapLayer": "Выбрать слой карты",
"mapOptions": "Параметры карты",
"showLegend": "Показать легенду",
"displayMarkerTypeCounts": "Отображать счётчики типов маркеров",
"rotateMapWithHeading": "Поворачивать карту по курсу",
"mapFollowsDirection": "Карта следует вашему направлению движения",
"resetMapRotation": "Сбросить поворот",
"resetMapRotationTooltip": "Вернуть карту на север",
"showMapDebugInfo": "Показать отладочную информацию карты",
"displayZoomLevelBounds": "Отображать уровень масштаба и границы",
"fullscreenMode": "Полноэкранный режим",
"hideUiFullMapView": "Скрыть все элементы интерфейса для полного вида карты",
"openStreetMap": "OpenStreetMap",
"openTopoMap": "OpenTopoMap",
"esriSatellite": "ESRI Спутник",
"googleHybrid": "Google Гибрид",
"googleRoadmap": "Google Дороги",
"googleTerrain": "Google Рельеф",
"downloadVisibleArea": "Скачать видимую область",
"initializingMap": "Инициализация карты...",
"dragToPosition": "Перетащите для позиционирования",
"createSarMarker": "Создать маркер SAR",
"compass": "Компас",
"navigationAndContacts": "Навигация и контакты",
"sarAlert": "ТРЕВОГА SAR",
"messageSentToPublicChannel": "Сообщение отправлено в публичный канал",
"pleaseSelectRoomToSendSar": "Пожалуйста, выберите комнату для отправки маркера SAR",
"failedToSendSarMarker": "Не удалось отправить маркер SAR: {error}",
"sarMarkerSentTo": "Маркер SAR отправлен в {roomName}",
"notConnectedCannotSync": "Нет подключения — синхронизация невозможна",
"syncedMessageCount": "Синхронизировано {count} сообщение(й)",
"noNewMessages": "Новых сообщений нет",
"syncFailed": "Синхронизация не удалась: {error}",
"failedToResendMessage": "Не удалось повторно отправить сообщение",
"retryingMessage": "Повторная отправка сообщения...",
"retryFailed": "Повтор не удался: {error}",
"textCopiedToClipboard": "Текст скопирован в буфер обмена",
"cannotReplySenderMissing": "Не удаётся ответить: нет информации об отправителе",
"cannotReplyContactNotFound": "Не удаётся ответить: контакт не найден",
"messageDeleted": "Сообщение удалено",
"copyText": "Копировать текст",
"saveAsTemplate": "Сохранить как шаблон",
"templateSaved": "Шаблон успешно сохранён",
"templateAlreadyExists": "Шаблон с таким эмодзи уже существует",
"deleteMessage": "Удалить сообщение",
"deleteMessageConfirmation": "Вы уверены, что хотите удалить это сообщение?",
"shareLocation": "Поделиться местоположением",
"shareLocationText": "{markerInfo}\n\nКоординаты: {lat}, {lon}\n\nGoogle Maps: {url}",
"sarLocationShare": "Местоположение SAR",
"locationShared": "Местоположение передано",
"refreshedContacts": "Контакты обновлены",
"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": "Вы",
"offlineVectorMaps": "Офлайн-векторные карты",
"offlineVectorMapsDescription": "Импортируйте офлайн-векторные тайлы карт (формат MBTiles) для использования без интернета",
"importMbtiles": "Импортировать файл MBTiles",
"importMbtilesNote": "Поддерживаются файлы MBTiles с векторными тайлами (формат PBF/MVT). Отлично подходят выгрузки Geofabrik!",
"noMbtilesFiles": "Офлайн-векторные карты не найдены",
"mbtilesImportedSuccessfully": "Файл MBTiles успешно импортирован",
"failedToImportMbtiles": "Не удалось импортировать файл MBTiles",
"deleteMbtilesConfirmTitle": "Удалить офлайн-карту",
"deleteMbtilesConfirmMessage": "Вы уверены, что хотите удалить \"{name}\"? Офлайн-карта будет удалена безвозвратно.",
"mbtilesDeletedSuccessfully": "Офлайн-карта успешно удалена",
"failedToDeleteMbtiles": "Не удалось удалить офлайн-карту",
"importExportCachedTiles": "Импорт/Экспорт кэшированных тайлов",
"importExportDescription": "Резервное копирование, обмен и восстановление загруженных тайлов карт между устройствами",
"exportTilesToFile": "Экспортировать тайлы в файл",
"importTilesFromFile": "Импортировать тайлы из файла",
"selectExportLocation": "Выберите место для экспорта",
"selectImportFile": "Выберите архив тайлов",
"exportingTiles": "Экспорт тайлов...",
"importingTiles": "Импорт тайлов...",
"exportSuccess": "Экспортировано {count} тайлов",
"importSuccess": "Импортировано {count} хранилищ",
"exportFailed": "Ошибка экспорта: {error}",
"importFailed": "Ошибка импорта: {error}",
"exportNote": "Создаёт сжатый архив (.fmtc), которым можно поделиться и импортировать на других устройствах.",
"importNote": "Импортирует тайлы карт из ранее экспортированного архива. Тайлы будут объединены с существующим кэшем.",
"noTilesToExport": "Нет доступных тайлов для экспорта",
"archiveContainsStores": "Архив содержит {count} хранилищ",
"vectorTiles": "Векторные тайлы",
"schema": "Схема",
"unknown": "Неизвестно",
"bounds": "Границы",
"onlineLayers": "Онлайн-слои",
"offlineLayers": "Офлайн-слои",
"locationTrail": "Трек местоположения",
"showTrailOnMap": "Показать трек на карте",
"trailVisible": "Трек отображается на карте",
"trailHiddenRecording": "Трек скрыт (запись продолжается)",
"duration": "Продолжительность",
"points": "Точки",
"clearTrail": "Очистить трек",
"clearTrailQuestion": "Очистить трек?",
"clearTrailConfirmation": "Вы уверены, что хотите очистить текущий трек? Это действие нельзя отменить.",
"noTrailRecorded": "Трек ещё не записан",
"startTrackingToRecord": "Запустите отслеживание геолокации для записи трека",
"trailControls": "Управление треком",
"exportTrailToGpx": "Экспортировать трек в GPX",
"importTrailFromGpx": "Импортировать трек из GPX",
"trailExportedSuccessfully": "Трек успешно экспортирован!",
"failedToExportTrail": "Не удалось экспортировать трек",
"failedToImportTrail": "Не удалось импортировать трек: {error}",
"importTrail": "Импортировать трек",
"importTrailQuestion": "Импортировать трек из {pointCount} точек?\n\nВы можете заменить текущий трек или просмотреть его рядом.",
"viewAlongside": "Просмотреть рядом",
"replaceCurrent": "Заменить текущий",
"trailImported": "Трек импортирован! ({pointCount} точек)",
"trailReplaced": "Трек заменён! ({pointCount} точек)",
"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": "Комнаты не найдены",
"noContactsOrRoomsAvailable": "Нет доступных контактов или комнат",
"noRecipientsAvailable": "Нет доступных получателей",
"noChannelsFound": "Каналы не найдены",
"messagesWillBeSentToPublicChannel": "Сообщения будут отправлены в публичный канал",
"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": "Прямоугольник",
"coordinatesCopiedToClipboard": "Координаты скопированы в буфер обмена",
"manualCoordinates": "Ввод координат вручную",
"enterCoordinatesManually": "Ввести координаты вручную",
"latitudeLabel": "Широта",
"longitudeLabel": "Долгота",
"invalidLatitude": "Неверная широта (от -90 до 90)",
"invalidLongitude": "Неверная долгота (от -180 до 180)",
"exampleCoordinates": "Пример: 55.7558, 37.6173",
"drawingShared": "Рисунок на карте",
"drawingHidden": "Рисунок скрыт с карты",
"alreadyShared": "{count, plural, =1{1 уже передан} other{{count} уже передано}}",
"newDrawingsShared": "Передано {count} новых рисунк{plural}",
"shareDrawing": "Поделиться рисунком",
"shareWithAllNearbyDevices": "Поделиться со всеми ближайшими устройствами",
"shareToRoom": "Отправить в комнату",
"sendToPersistentStorage": "Отправить в постоянное хранилище комнаты",
"deleteDrawingConfirm": "Вы уверены, что хотите удалить этот рисунок?",
"drawingDeleted": "Рисунок удалён",
"yourDrawingsCount": "Ваши рисунки ({count})",
"shared": "Передано",
"line": "Линия",
"rectangle": "Прямоугольник",
"updateAvailable": "Доступно обновление",
"currentVersion": "Текущая",
"latestVersion": "Последняя",
"downloadUpdate": "Скачать",
"updateLater": "Позже",
"cadastralParcels": "Кадастровые участки",
"forestRoads": "Лесные дороги",
"showCadastralParcels": "Показать кадастровые участки",
"showForestRoads": "Показать лесные дороги",
"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}",
"allChannelSlotsInUse": "Все слоты каналов заняты (максимум 39 пользовательских каналов)",
"createChannel": "Создать канал",
"wizardBack": "Назад",
"wizardSkip": "Пропустить",
"wizardNext": "Далее",
"wizardGetStarted": "Начать",
"wizardWelcomeTitle": "Добро пожаловать в MeshCore SAR",
"wizardWelcomeDescription": "Мощный инструмент связи вне сети для поисково-спасательных операций. Поддерживайте связь с командой через mesh-радио, когда традиционные сети недоступны.",
"wizardConnectingTitle": "Подключение к радиоустройству",
"wizardConnectingDescription": "Подключите смартфон к радиоустройству MeshCore по Bluetooth для связи вне сети.",
"wizardConnectingFeature1": "Поиск ближайших устройств MeshCore",
"wizardConnectingFeature2": "Сопряжение с радиоустройством по Bluetooth",
"wizardConnectingFeature3": "Работает полностью офлайн — интернет не нужен",
"wizardSimpleModeTitle": "Простой режим",
"wizardSimpleModeDescription": "Впервые работаете с mesh-сетью? Включите простой режим для упрощённого интерфейса с основными функциями.",
"wizardSimpleModeFeature1": "Удобный для новичков интерфейс с основными функциями",
"wizardSimpleModeFeature2": "Переключиться в расширенный режим можно в любое время в настройках",
"wizardChannelTitle": "Каналы",
"wizardChannelDescription": "Транслируйте сообщения всем участникам канала — идеально для общих объявлений и координации команды.",
"wizardChannelFeature1": "Публичный канал для общей связи команды",
"wizardChannelFeature2": "Создавайте пользовательские каналы для отдельных групп",
"wizardChannelFeature3": "Сообщения автоматически ретранслируются через mesh",
"wizardContactsTitle": "Контакты",
"wizardContactsDescription": "Члены вашей команды появляются автоматически по мере подключения к mesh-сети. Отправляйте им личные сообщения или просматривайте их местоположение.",
"wizardContactsFeature1": "Контакты обнаруживаются автоматически",
"wizardContactsFeature2": "Отправка личных сообщений",
"wizardContactsFeature3": "Просмотр уровня заряда и времени последнего появления",
"wizardMapTitle": "Карта и местоположение",
"wizardMapDescription": "Отслеживайте команду в реальном времени и отмечайте важные места для поисково-спасательных операций.",
"wizardMapFeature1": "Маркеры SAR для найденных людей, пожаров и мест сбора",
"wizardMapFeature2": "GPS-отслеживание членов команды в реальном времени",
"wizardMapFeature3": "Загрузка офлайн-карт для отдалённых районов",
"wizardMapFeature4": "Рисование фигур и обмен тактической информацией",
"viewWelcomeTutorial": "Просмотреть обучение",
"allTeamContacts": "Все контакты команды",
"directMessagesInfo": "Личные сообщения с подтверждением. Отправлено {count} членам команды.",
"sarMarkerSentToContacts": "Маркер SAR отправлен {count} контактам",
"noContactsAvailable": "Нет доступных контактов команды",
"reply": "Ответить",
"technicalDetails": "Технические детали",
"messageTechnicalDetails": "Технические детали сообщения",
"linkQuality": "Качество связи",
"delivery": "Доставка",
"status": "Статус",
"expectedAckTag": "Ожидаемый тег ACK",
"roundTrip": "Время отклика",
"retryAttempt": "Попытка повтора",
"floodFallback": "Широковещательный резерв",
"identity": "Идентификатор",
"messageId": "ID сообщения",
"sender": "Отправитель",
"senderKey": "Ключ отправителя",
"recipient": "Получатель",
"recipientKey": "Ключ получателя",
"voice": "Голос",
"voiceId": "ID голоса",
"envelope": "Конверт",
"sessionProgress": "Прогресс сессии",
"complete": "Завершено",
"rawDump": "Необработанные данные",
"cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе",
"voiceUnavailable": "Голос сейчас недоступен",
"requestingVoice": "Запрос голоса"
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,9 +14,9 @@ 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/update_checker_service.dart';
@@ -234,6 +234,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
},
),
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()),
// Voice provider (packet reassembly + playback)
ChangeNotifierProvider(
@@ -246,18 +247,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 +265,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 +274,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
messages,
drawings,
channels,
tileCache,
previous,
) =>
previous ??
@@ -290,7 +285,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
channelsProvider: channels,
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
tileCacheService: tileCache,
),
),
],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,11 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:crypto/crypto.dart';
import '../models/contact.dart';
import '../models/device_info.dart';
import '../models/room_login_state.dart';
import '../models/sse_server_config.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:meshcore_client/meshcore_client.dart' hide Contact;
import '../services/sse_server_service.dart';
import '../utils/sar_message_parser.dart';
import 'helpers/room_login_manager.dart';
@@ -89,6 +90,8 @@ class ConnectionProvider with ChangeNotifier {
bool _isScanning = false;
bool get isScanning => _isScanning;
bool _isSpectrumScanActive = false;
bool get isSpectrumScanActive => _isSpectrumScanActive;
String? _error;
String? get error => _error;
@@ -144,11 +147,15 @@ class ConnectionProvider with ChangeNotifier {
final MessageDeliveryTracker _messageDeliveryTracker =
MessageDeliveryTracker();
final PingTracker _pingTracker = PingTracker();
final Map<String, Future<PingResult>> _pendingSmartPings = {};
// Expose room login states
Map<String, RoomLoginState> get roomLoginStates =>
_roomLoginManager.roomLoginStates;
bool isPingInProgress(Uint8List publicKey) =>
_pendingSmartPings.containsKey(_publicKeyToHex(publicKey));
// Callbacks for other providers
Function(Contact)? onContactReceived;
Function(List<Contact>)? onContactsComplete;
@@ -247,7 +254,10 @@ class ConnectionProvider with ChangeNotifier {
await Future.delayed(const Duration(milliseconds: 300));
if (pendingOp.messageId != null) {
_messageDeliveryTracker.trackPendingMessage(pendingOp.messageId!);
_messageDeliveryTracker.trackPendingDirectMessage(
pendingOp.messageId!,
pendingOp.contactPublicKey,
);
}
await _activeService.sendTextMessage(
@@ -312,6 +322,10 @@ class ConnectionProvider with ChangeNotifier {
};
service.onMessageWaiting = () {
if (_isSpectrumScanActive) {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return;
}
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
if (_isSyncingMessages) {
_syncRequestedWhileBusy = true;
@@ -346,7 +360,11 @@ class ConnectionProvider with ChangeNotifier {
service.onMessageSent =
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
final messageId = _messageDeliveryTracker.popPendingMessageId();
final messageId = contactPublicKey != null
? _messageDeliveryTracker.popPendingDirectMessageId(
contactPublicKey,
)
: _messageDeliveryTracker.popPendingMessageId();
if (messageId != null) {
_messageDeliveryTracker.mapAckTagToMessageId(
expectedAckTag,
@@ -379,6 +397,9 @@ class ConnectionProvider with ChangeNotifier {
manufacturerModel: deviceInfo['manufacturerModel'] as String?,
semanticVersion: deviceInfo['semanticVersion'] as String?,
clientRepeat: deviceInfo['clientRepeat'] as bool?,
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
);
notifyListeners();
if (_sseServer.isRunning) {
@@ -1101,9 +1122,11 @@ class ConnectionProvider with ChangeNotifier {
);
}
debugPrint(' Type: ${contact.type.displayName}');
debugPrint(' Path status: ${contact.pathDescription}');
if (contact.hasPath) {
debugPrint(' ✅ Using learned path (${contact.outPathLen} bytes)');
debugPrint(' Path status: ${contact.routeSummary}');
if (contact.routeHasPath) {
debugPrint(
' ✅ Using learned path (${contact.routeHopCount} hop(s), ${contact.routeHashSize}-byte hashes)',
);
} else {
debugPrint(' ⚠️ No path available - will use flood mode');
}
@@ -1139,7 +1162,10 @@ class ConnectionProvider with ChangeNotifier {
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
// We still track here for the SENT response callback to work.
if (messageId != null) {
_messageDeliveryTracker.trackPendingMessage(messageId);
_messageDeliveryTracker.trackPendingDirectMessage(
messageId,
contactPublicKey,
);
debugPrint(' 📝 Tracked pending message: $messageId');
}
@@ -1150,6 +1176,19 @@ class ConnectionProvider with ChangeNotifier {
attempt: retryAttempt,
);
if (messageId != null) {
Future.delayed(const Duration(milliseconds: 350), () {
if (_messageDeliveryTracker.hasAckForMessage(messageId)) {
return;
}
debugPrint(
' [ConnectionProvider] Missing RESP_CODE_SENT for $messageId; promoting to sent via fallback',
);
onMessageSent?.call(messageId, 0, 0);
});
}
// Clear pending operation after successful send (no error)
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
if (contact != null) {
@@ -1251,6 +1290,19 @@ class ConnectionProvider with ChangeNotifier {
);
}
/// Send a raw private zero-hop payload.
///
/// This wraps the raw custom transport using an empty path to match the
/// firmware's private multicast behavior.
Future<void> sendRawPrivateMulticast(Uint8List payload) async {
if (!_activeService.isConnected) return;
await _activeService.sendRawVoicePacket(
contactPathLen: 0,
contactPath: Uint8List(0),
payload: payload,
);
}
/// Request telemetry from contact
///
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
@@ -1297,6 +1349,34 @@ class ConnectionProvider with ChangeNotifier {
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async {
final pingKey = _publicKeyToHex(contactPublicKey);
final pendingPing = _pendingSmartPings[pingKey];
if (pendingPing != null) {
debugPrint(' [Provider] Joining in-flight ping for $pingKey');
return pendingPing;
}
final future = _runSmartPing(
contactPublicKey: contactPublicKey,
hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
_pendingSmartPings[pingKey] = future;
notifyListeners();
try {
return await future;
} finally {
_pendingSmartPings.remove(pingKey);
notifyListeners();
}
}
Future<PingResult> _runSmartPing({
required Uint8List contactPublicKey,
required bool hasPath,
Function()? onRetryWithFlooding,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -1315,7 +1395,10 @@ class ConnectionProvider with ChangeNotifier {
);
// Send the ping
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
await _activeService.requestTelemetry(
contactPublicKey,
zeroHop: firstAttemptDirect,
);
// Wait for response or timeout
final bool gotResponse = await pingFuture;
@@ -1342,8 +1425,8 @@ class ConnectionProvider with ChangeNotifier {
wasDirectAttempt: false,
);
// Retry with flooding (zeroHop=true acts as broadcast to neighbors)
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true);
// Retry with flooding.
await _activeService.requestTelemetry(contactPublicKey, zeroHop: false);
// Wait for response or timeout
final bool gotRetryResponse = await retryFuture;
@@ -1365,6 +1448,10 @@ class ConnectionProvider with ChangeNotifier {
}
}
String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
/// Send binary request to contact (modern replacement for requestTelemetry)
///
/// Supports multiple request types:
@@ -1555,6 +1642,43 @@ class ConnectionProvider with ChangeNotifier {
}
}
Future<SpectrumScanResult?> scanSpectrum({
required int startFrequencyKhz,
required int stopFrequencyKhz,
required int bandwidthKhz,
required int stepKhz,
required int dwellMs,
required int thresholdDb,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return null;
}
try {
_isSpectrumScanActive = true;
_activeService.setSpectrumScanActive(true);
notifyListeners();
return await _activeService.scanSpectrum(
startFrequencyKhz: startFrequencyKhz,
stopFrequencyKhz: stopFrequencyKhz,
bandwidthKhz: bandwidthKhz,
stepKhz: stepKhz,
dwellMs: dwellMs,
thresholdDb: thresholdDb,
);
} catch (e) {
_error = 'Failed to scan spectrum: $e';
notifyListeners();
return null;
} finally {
_isSpectrumScanActive = false;
_activeService.setSpectrumScanActive(false);
notifyListeners();
}
}
/// Set transmit power
Future<void> setTxPower(int powerDbm) async {
if (!_activeService.isConnected) {
@@ -1599,6 +1723,7 @@ class ConnectionProvider with ChangeNotifier {
/// Request fresh device info (triggers SelfInfo response)
Future<void> refreshDeviceInfo() async {
if (_isSpectrumScanActive) return;
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
@@ -1642,6 +1767,7 @@ class ConnectionProvider with ChangeNotifier {
/// Sync messages from device queue
/// Call this repeatedly until no more messages are available
Future<bool> syncNextMessage() async {
if (_isSpectrumScanActive) return false;
// Prevent re-entrancy and too-fast triggers
if (_isSyncingMessages) {
// Another sync (single or loop) is in progress
@@ -1680,6 +1806,10 @@ class ConnectionProvider with ChangeNotifier {
/// Sync all waiting messages from device
Future<int> syncAllMessages() async {
if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0;
}
if (_isSyncingMessages) {
// Already syncing; avoid overlapping loops
_syncRequestedWhileBusy = true;
@@ -1898,6 +2028,7 @@ class ConnectionProvider with ChangeNotifier {
}
try {
_error = null;
await _activeService.resetPath(contactPublicKey);
} catch (e) {
_error = 'Failed to reset path: $e';
@@ -1905,6 +2036,31 @@ class ConnectionProvider with ChangeNotifier {
}
}
Future<void> setContactRoute(
Contact contact, {
required int signedEncodedPathLen,
required Uint8List paddedPathBytes,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
_error = null;
final updatedContact = contact.copyWith(
outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes),
);
await _activeService.addOrUpdateContact(updatedContact);
} catch (e) {
_error = 'Failed to set route: $e';
notifyListeners();
rethrow;
}
}
/// Remove a contact from the companion radio
///
/// Deletes the contact from the device's internal contact table.

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import '../../models/message.dart';
import '../../models/contact.dart';
@@ -17,11 +19,18 @@ class MessageRetryManager {
// Track retry state for each message ID
final Map<String, int> _retryAttempts = {};
final Map<String, DateTime> _lastRetryTimes = {};
final Map<String, int> _pathFailureStreaks = {};
// Progressive timeout values in milliseconds
// These are app-level timeouts, separate from firmware's suggested timeout
// Firmware timeout is for ACK arrival, these are for retry attempts
static const List<int> _timeouts = [4000, 8000, 12000];
static const int _defaultLoRaSf = 10;
static const int _defaultLoRaCr = 5;
static const int _defaultLoRaBwHz = 250000;
static const int _defaultLoRaPreambleSymbols = 8;
static const int _defaultLoRaCrcEnabled = 1;
static const int _defaultLoRaExplicitHeader = 1;
/// Get timeout for a specific retry attempt (0-2)
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
@@ -32,6 +41,30 @@ class MessageRetryManager {
return _timeouts[attempt];
}
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
/// provide one or returns an invalid value.
int calculateAckTimeoutMs({
required String text,
required Contact? contact,
int? suggestedTimeoutMs,
}) {
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
return suggestedTimeoutMs;
}
final payloadBytes = utf8.encode(text).length;
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
final hopCount = contact?.routeHasPath == true
? contact!.routeHopCount
: -1;
if (hopCount < 0) {
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
}
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
}
/// Check if a message is eligible for retry
///
/// Returns true if:
@@ -53,7 +86,7 @@ class MessageRetryManager {
// Only retry if contact has a learned path
// If no path, the device uses flood mode automatically - retrying won't help
return contact.hasPath;
return contact.routeHasPath;
}
/// Check if should fall back to flood mode
@@ -67,8 +100,8 @@ class MessageRetryManager {
/// 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;
contact.routeHasPath &&
!message.usedFloodFallback;
}
/// Track a retry attempt for a message
@@ -87,6 +120,7 @@ class MessageRetryManager {
void clearAll() {
_retryAttempts.clear();
_lastRetryTimes.clear();
_pathFailureStreaks.clear();
}
/// Get current retry attempt for a message (for debugging)
@@ -98,4 +132,50 @@ class MessageRetryManager {
DateTime? getLastRetryTime(String messageId) {
return _lastRetryTimes[messageId];
}
/// Record a successful delivery for a contact and clear any accumulated
/// route failure streak for future sends.
void recordDeliverySuccess(Contact contact) {
_pathFailureStreaks.remove(contact.publicKeyHex);
}
/// Record a permanent route failure for a contact.
///
/// Returns the updated failure streak so callers can decide when to reset
/// the learned path on the radio and in local state.
int recordPathFailure(Contact contact) {
final contactKey = contact.publicKeyHex;
final next = (_pathFailureStreaks[contactKey] ?? 0) + 1;
_pathFailureStreaks[contactKey] = next;
return next;
}
int? getPathFailureStreak(Contact contact) {
return _pathFailureStreaks[contact.publicKeyHex];
}
int _estimateLoRaAirtimeMs(int payloadLenBytes) {
final sf = _defaultLoRaSf;
final bw = _defaultLoRaBwHz.toDouble();
final cr = (_defaultLoRaCr - 4).clamp(1, 4);
final ih = _defaultLoRaExplicitHeader == 1 ? 0 : 1;
final de = (sf >= 11 && _defaultLoRaBwHz <= 125000) ? 1 : 0;
final symbolMs = ((1 << sf) / bw) * 1000.0;
final preambleMs = (_defaultLoRaPreambleSymbols + 4.25) * symbolMs;
final num =
(8 * payloadLenBytes) -
(4 * sf) +
28 +
(16 * _defaultLoRaCrcEnabled) -
(20 * ih);
final den = 4 * (sf - (2 * de));
final payloadSymCoeff = den <= 0 ? 0 : (num / den).ceil();
final payloadSymbols =
8 + (payloadSymCoeff < 0 ? 0 : payloadSymCoeff) * (cr + 4);
final payloadMs = payloadSymbols * symbolMs;
return (preambleMs + payloadMs).ceil();
}
}

View File

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

View File

@@ -9,13 +9,6 @@ typedef RawPacketSender =
required Uint8List payload,
});
typedef FragmentAckWaiter =
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
});
Future<bool> serveCachedSessionFragments<T>({
required String providerLabel,
required String sessionId,
@@ -25,9 +18,7 @@ Future<bool> serveCachedSessionFragments<T>({
required int Function(T fragment) indexOf,
required Uint8List Function(T fragment) encodeBinary,
required RawPacketSender? sendRawPacket,
FragmentAckWaiter? waitForFragmentAck,
Set<int>? requestedIndices,
Duration ackTimeout = const Duration(seconds: 8),
}) async {
if (fragments.isEmpty) {
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
@@ -37,13 +28,19 @@ 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;
}
if (!requester.routeSupportsLegacyRawTransport) {
debugPrint(
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
);
return false;
}
@@ -65,24 +62,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.routeSignedPathLen,
payload: encodeBinary(fragment),
);
servedCount++;
if (ackFuture != null) {
final acked = await ackFuture;
if (!acked) {
debugPrint('⚠️ [$providerLabel] ACK timeout for $sessionId#$index');
return false;
}
}
} catch (e, st) {
debugPrint(
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',

View File

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

View File

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

View File

@@ -2,6 +2,9 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/message.dart';
import '../models/contact.dart';
import '../models/message_contact_location.dart';
import '../models/message_reception_details.dart';
import '../models/message_transfer_details.dart';
import '../models/sar_marker.dart';
import '../models/map_drawing.dart';
import '../services/message_storage_service.dart';
@@ -9,6 +12,7 @@ import '../services/notification_service.dart';
import '../utils/sar_message_parser.dart';
import '../utils/drawing_message_parser.dart';
import '../utils/voice_message_parser.dart';
import '../utils/image_message_parser.dart';
import '../l10n/app_localizations.dart';
import 'helpers/message_retry_manager.dart';
@@ -20,6 +24,9 @@ class MessagesProvider with ChangeNotifier {
final NotificationService _notificationService = NotificationService();
bool _isInitialized = false;
AppLocalizations? _localizations;
final Map<String, MessageContactLocation> _messageContactLocations = {};
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
// Track pending sent messages by expected ACK/TAG
final Map<int, Message> _pendingSentMessages = {};
@@ -28,6 +35,13 @@ class MessagesProvider with ChangeNotifier {
// Key: message ID (not ACK tag, since multiple messages can share same ACK)
final Map<String, Timer> _timeoutTimers = {};
// Recently completed ACKs are kept briefly to ignore duplicate confirms.
final Map<int, DateTime> _completedAckHistory = {};
// Preserve ACK tags assigned to a message across retries.
final Map<String, Set<int>> _messageAckHistory = {};
final Map<int, (String, DateTime)> _ackHistoryLookup = {};
// Retry management
final MessageRetryManager _retryManager = MessageRetryManager();
@@ -65,6 +79,12 @@ class MessagesProvider with ChangeNotifier {
})?
sendMessageCallback;
Future<void> Function({required Contact contact, required int failureStreak})?
onDirectPathFailedCallback;
String? Function(Uint8List? publicKey)? resolveContactNameCallback;
String Function(int channelIdx)? resolveChannelNameCallback;
List<Message> get messages => List.unmodifiable(_messages);
List<Message> get contactMessages =>
@@ -97,6 +117,15 @@ class MessagesProvider with ChangeNotifier {
String? get targetMessageId => _targetMessageId;
MessageContactLocation? getMessageContactLocation(String messageId) =>
_messageContactLocations[messageId];
MessageReceptionDetails? getMessageReceptionDetails(String messageId) =>
_messageReceptionDetails[messageId];
MessageTransferDetails? getMessageTransferDetails(String messageId) =>
_messageTransferDetails[messageId];
/// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) {
_localizations = localizations;
@@ -125,6 +154,21 @@ class MessagesProvider with ChangeNotifier {
try {
debugPrint('📦 [MessagesProvider] Loading persisted messages...');
final storedMessages = await _storageService.loadMessages();
final storedContactLocations = await _storageService
.loadMessageContactLocations();
final storedReceptionDetails = await _storageService
.loadMessageReceptionDetails();
final storedTransferDetails = await _storageService
.loadMessageTransferDetails();
_messageContactLocations
..clear()
..addAll(storedContactLocations);
_messageReceptionDetails
..clear()
..addAll(storedReceptionDetails);
_messageTransferDetails
..clear()
..addAll(storedTransferDetails);
// Add stored messages with enhancement to ensure SAR detection
for (final message in storedMessages) {
@@ -164,14 +208,6 @@ class MessagesProvider with ChangeNotifier {
isVoice: true,
voiceId: envelope.sessionId,
);
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
if (pkt != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
}
}
@@ -287,6 +323,8 @@ class MessagesProvider with ChangeNotifier {
void addMessage(
Message message, {
String Function(String name)? contactLookup,
MessageContactLocation? contactLocationSnapshot,
MessageReceptionDetails? receptionDetailsSnapshot,
}) {
// Always enhance message with SAR parser to detect SAR markers
var enhancedMessage = SarMessageParser.enhanceMessage(message);
@@ -317,14 +355,6 @@ class MessagesProvider with ChangeNotifier {
isVoice: true,
voiceId: envelope.sessionId,
);
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
if (pkt != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
}
}
@@ -373,10 +403,32 @@ class MessagesProvider with ChangeNotifier {
debugPrint(
' Text: ${finalMessage.text.substring(0, finalMessage.text.length > 50 ? 50 : finalMessage.text.length)}...',
);
final existingIndex = _messages.indexWhere(
(existing) =>
existing.messageType == finalMessage.messageType &&
existing.senderTimestamp == finalMessage.senderTimestamp &&
existing.text == finalMessage.text,
);
if (existingIndex != -1) {
final existingId = _messages[existingIndex].id;
if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot;
}
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[existingId] = receptionDetailsSnapshot;
}
_persistMessages();
}
return; // Skip duplicate
}
_messages.add(finalMessage);
if (contactLocationSnapshot != null) {
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
}
if (receptionDetailsSnapshot != null) {
_messageReceptionDetails[finalMessage.id] = receptionDetailsSnapshot;
}
// If it's a SAR marker message, extract and store the marker
if (finalMessage.isSarMarker) {
@@ -522,33 +574,31 @@ class MessagesProvider with ChangeNotifier {
/// Trigger notification for regular message
Future<void> _triggerMessageNotification(Message message) async {
try {
// Get sender name from message
final senderName =
message.senderName ?? message.senderKeyShort ?? 'Unknown';
// Determine if it's a channel message
final senderName = _resolveParticipantName(
publicKey: message.senderPublicKeyPrefix,
fallback: message.senderName ?? message.senderKeyShort,
);
final isChannelMessage = message.isChannelMessage;
// Get channel name if available
String? channelName;
if (isChannelMessage) {
// You could map channelIdx to channel name here if needed
// For now, use "Public" for channel 0
channelName = message.channelIdx == 0
? 'Public'
: 'Channel ${message.channelIdx}';
}
final channelName = isChannelMessage
? _resolveChannelName(message.channelIdx)
: null;
final messageText = _buildNotificationMessageText(
message,
senderName: senderName,
isChannelMessage: isChannelMessage,
channelName: channelName,
);
debugPrint('🔔 [MessagesProvider] Triggering message notification');
debugPrint(' Sender: $senderName');
debugPrint(' Type: ${isChannelMessage ? "Channel" : "Direct"}');
debugPrint(
' Message: ${message.text.substring(0, message.text.length > 50 ? 50 : message.text.length)}...',
' Message: ${messageText.substring(0, messageText.length > 50 ? 50 : messageText.length)}...',
);
await _notificationService.showMessageNotification(
senderName: senderName,
messageText: message.text,
messageText: messageText,
isChannelMessage: isChannelMessage,
channelName: channelName,
localizations: _localizations,
@@ -560,10 +610,85 @@ class MessagesProvider with ChangeNotifier {
}
}
String _resolveParticipantName({
required Uint8List? publicKey,
String? fallback,
}) {
final resolved = resolveContactNameCallback?.call(publicKey)?.trim();
if (resolved != null && resolved.isNotEmpty) {
return resolved;
}
final normalizedFallback = fallback?.trim();
if (normalizedFallback != null && normalizedFallback.isNotEmpty) {
return normalizedFallback;
}
return 'Unknown';
}
String _resolveChannelName(int? channelIdx) {
final idx = channelIdx ?? 0;
final resolved = resolveChannelNameCallback?.call(idx).trim();
if (resolved != null && resolved.isNotEmpty) {
return resolved;
}
return idx == 0 ? 'Public' : 'Channel $idx';
}
String _buildNotificationMessageText(
Message message, {
required String senderName,
required bool isChannelMessage,
String? channelName,
}) {
final voiceEnvelope = VoiceEnvelope.tryParseText(message.text);
if (voiceEnvelope != null) {
final seconds = (voiceEnvelope.durationMs / 1000).ceil();
final summary =
'Voice message - ${voiceEnvelope.mode.label} - ${seconds}s - ${voiceEnvelope.total} packets';
return isChannelMessage ? '$senderName\n$summary' : summary;
}
final imageEnvelope = ImageEnvelope.tryParse(message.text);
if (imageEnvelope != null) {
final summary =
'Image - ${imageEnvelope.format.label} - ${imageEnvelope.width}x${imageEnvelope.height} - ${_formatBytes(imageEnvelope.sizeBytes)}';
return isChannelMessage ? '$senderName\n$summary' : summary;
}
if (!isChannelMessage && message.recipientPublicKey != null) {
final recipientName = _resolveParticipantName(
publicKey: message.recipientPublicKey,
fallback: null,
);
if (recipientName != 'Unknown') {
return 'To: $recipientName\n${message.text}';
}
}
if (isChannelMessage) {
return '$senderName\n${message.text}';
}
return message.text;
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
final kib = bytes / 1024;
if (kib < 1024) return '${kib.toStringAsFixed(kib >= 10 ? 0 : 1)} KB';
final mib = kib / 1024;
return '${mib.toStringAsFixed(mib >= 10 ? 0 : 1)} MB';
}
/// Persist messages to storage (async, non-blocking)
Future<void> _persistMessages() async {
try {
await _storageService.saveMessages(_messages);
await _storageService.saveMessages(
_messages,
messageContactLocations: _messageContactLocations,
messageReceptionDetails: _messageReceptionDetails,
messageTransferDetails: _messageTransferDetails,
);
} catch (e) {
debugPrint('❌ [MessagesProvider] Error persisting messages: $e');
}
@@ -678,6 +803,9 @@ class MessagesProvider with ChangeNotifier {
}
_messageContactMap.remove(messageId);
_groupedMessageMapping.remove(messageId);
_messageContactLocations.remove(messageId);
_messageReceptionDetails.remove(messageId);
_messageTransferDetails.remove(messageId);
debugPrint('🗑️ [MessagesProvider] Message $messageId deleted');
@@ -706,6 +834,9 @@ class MessagesProvider with ChangeNotifier {
void clearMessages() {
_messages.clear();
_sarMarkers.clear();
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_persistMessages();
notifyListeners();
}
@@ -720,10 +851,71 @@ class MessagesProvider with ChangeNotifier {
void clearAll() {
_messages.clear();
_sarMarkers.clear();
_messageContactLocations.clear();
_messageReceptionDetails.clear();
_messageTransferDetails.clear();
_persistMessages();
notifyListeners();
}
int transferCountForSession({
String? voiceSessionId,
String? imageSessionId,
}) {
final messageId = _findMessageIdByMediaSession(
voiceSessionId: voiceSessionId,
imageSessionId: imageSessionId,
);
if (messageId == null) return 0;
return _messageTransferDetails[messageId]?.totalTransfers ?? 0;
}
void recordMediaTransfer({
required String sessionId,
required String mediaType,
required String requesterKey6,
String? requesterName,
}) {
final messageId = _findMessageIdByMediaSession(
voiceSessionId: mediaType == 'voice' ? sessionId : null,
imageSessionId: mediaType == 'image' ? sessionId : null,
);
if (messageId == null) {
debugPrint(
'⚠️ [MessagesProvider] No message found for $mediaType session $sessionId',
);
return;
}
final current =
_messageTransferDetails[messageId] ??
const MessageTransferDetails.empty();
_messageTransferDetails[messageId] = current.registerTransfer(
requesterKey6: requesterKey6,
requesterName: requesterName,
);
_persistMessages();
notifyListeners();
}
String? _findMessageIdByMediaSession({
String? voiceSessionId,
String? imageSessionId,
}) {
for (final message in _messages.reversed) {
if (voiceSessionId != null && message.voiceId == voiceSessionId) {
return message.id;
}
if (imageSessionId != null) {
final envelope = ImageEnvelope.tryParse(message.text);
if (envelope != null && envelope.sessionId == imageSessionId) {
return message.id;
}
}
}
return null;
}
/// Get storage statistics
Future<Map<String, dynamic>> getStorageStats() async {
return await _storageService.getStorageStats();
@@ -819,14 +1011,6 @@ class MessagesProvider with ChangeNotifier {
isVoice: true,
voiceId: envelope.sessionId,
);
} else if (VoicePacket.isVoiceText(enhancedMessage.text)) {
final pkt = VoicePacket.tryParseText(enhancedMessage.text);
if (pkt != null) {
enhancedMessage = enhancedMessage.copyWith(
isVoice: true,
voiceId: pkt.sessionId,
);
}
}
}
@@ -906,11 +1090,13 @@ class MessagesProvider with ChangeNotifier {
final (groupId, recipientPublicKey) = groupMapping;
debugPrint(' ✅ This is part of a grouped message: $groupId');
// Update the recipient status to "sent" in the grouped message
// ACK-tracked recipients stay pending until the delivery confirm arrives.
updateGroupedMessageRecipientStatus(
groupId,
recipientPublicKey,
MessageDeliveryStatus.sent,
expectedAckTag > 0
? MessageDeliveryStatus.sending
: MessageDeliveryStatus.sent,
);
// Track the ACK for this specific recipient
@@ -1015,23 +1201,36 @@ class MessagesProvider with ChangeNotifier {
if (index != -1) {
final message = _messages[index];
final contact = _messageContactMap[messageId];
final effectiveTimeout = _retryManager.calculateAckTimeoutMs(
text: message.text,
contact: contact,
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
);
debugPrint(' Current status: ${message.deliveryStatus}');
debugPrint(' Message type: ${message.messageType}');
debugPrint(
' Message text preview: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...',
);
// Once the device accepts a direct message and returns an ACK tag, the
// send itself succeeded locally even if end-to-end delivery confirmation
// may still arrive later. Keep ACK tracking, but stop showing "waiting".
final updatedMessage = message.copyWith(
deliveryStatus: MessageDeliveryStatus.sent,
expectedAckTag: expectedAckTag > 0 ? expectedAckTag : null,
suggestedTimeoutMs: suggestedTimeoutMs > 0 ? suggestedTimeoutMs : null,
suggestedTimeoutMs: expectedAckTag > 0 ? effectiveTimeout : null,
);
_messages[index] = updatedMessage;
// Only track and set timeout for direct messages (channel messages have expectedAckTag=0)
if (expectedAckTag > 0 && suggestedTimeoutMs > 0) {
if (expectedAckTag > 0) {
// Track by ACK tag for matching with delivery confirmation
_pendingSentMessages[expectedAckTag] = updatedMessage;
_messageAckHistory
.putIfAbsent(messageId, () => <int>{})
.add(expectedAckTag);
_ackHistoryLookup[expectedAckTag] = (messageId, DateTime.now());
debugPrint(
' ✅ Added to pending messages map with ACK: $expectedAckTag',
);
@@ -1042,7 +1241,7 @@ class MessagesProvider with ChangeNotifier {
// Start timeout timer using message ID as key
_timeoutTimers[messageId] = Timer(
Duration(milliseconds: suggestedTimeoutMs),
Duration(milliseconds: effectiveTimeout),
() {
debugPrint(
'⏱️ [MessagesProvider] Timeout for message $messageId (ACK $expectedAckTag)',
@@ -1054,7 +1253,7 @@ class MessagesProvider with ChangeNotifier {
);
debugPrint(
'⏱️ [MessagesProvider] Started ${suggestedTimeoutMs}ms timeout timer for message $messageId (ACK $expectedAckTag)',
'⏱️ [MessagesProvider] Started ${effectiveTimeout}ms timeout timer for message $messageId (ACK $expectedAckTag)',
);
} else {
debugPrint(
@@ -1245,6 +1444,8 @@ class MessagesProvider with ChangeNotifier {
/// Update message status to delivered with RTT
void markMessageDelivered(int ackCode, int roundTripTimeMs) {
_cleanupCompletedAckHistory();
_cleanupAckHistoryLookup();
debugPrint(
'🔍 [MessagesProvider] markMessageDelivered called with ACK: $ackCode, RTT: ${roundTripTimeMs}ms',
);
@@ -1296,6 +1497,7 @@ class MessagesProvider with ChangeNotifier {
);
_ackTagToRecipients.remove(ackCode);
_pendingSentMessages.remove(ackCode);
_rememberCompletedAck(ackCode);
}
debugPrint(
@@ -1339,9 +1541,15 @@ class MessagesProvider with ChangeNotifier {
// Remove from pending
_pendingSentMessages.remove(ackCode);
_rememberCompletedAck(ackCode);
_clearAckHistoryForMessage(message.id);
// Clear retry tracking on successful delivery
_retryManager.clearRetry(message.id);
final deliveredContact = _messageContactMap[message.id];
if (deliveredContact != null) {
_retryManager.recordDeliverySuccess(deliveredContact);
}
debugPrint(
'✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)',
@@ -1362,6 +1570,43 @@ class MessagesProvider with ChangeNotifier {
);
}
} else {
final historicalMatch = _ackHistoryLookup[ackCode];
if (historicalMatch != null) {
final historicalMessageId = historicalMatch.$1;
final historicalIndex = _messages.indexWhere(
(m) => m.id == historicalMessageId,
);
if (historicalIndex != -1 &&
_messages[historicalIndex].deliveryStatus !=
MessageDeliveryStatus.delivered) {
_messages[historicalIndex] = _messages[historicalIndex].copyWith(
deliveryStatus: MessageDeliveryStatus.delivered,
roundTripTimeMs: roundTripTimeMs,
deliveredAt: DateTime.now(),
);
_timeoutTimers[historicalMessageId]?.cancel();
_timeoutTimers.remove(historicalMessageId);
_rememberCompletedAck(ackCode);
_clearAckHistoryForMessage(historicalMessageId);
_retryManager.clearRetry(historicalMessageId);
final deliveredContact = _messageContactMap[historicalMessageId];
if (deliveredContact != null) {
_retryManager.recordDeliverySuccess(deliveredContact);
}
_persistMessages();
notifyListeners();
debugPrint(
'✅ [MessagesProvider] Historical ACK $ackCode matched message $historicalMessageId',
);
return;
}
}
if (_completedAckHistory.containsKey(ackCode)) {
debugPrint(
' [MessagesProvider] Duplicate/late ACK $ackCode ignored (already completed)',
);
return;
}
debugPrint(
'⚠️ [MessagesProvider] No pending message found for ACK code: $ackCode',
);
@@ -1429,7 +1674,7 @@ class MessagesProvider with ChangeNotifier {
debugPrint('❌ [MessagesProvider] Message $messageId timeout/failed');
debugPrint(' Retry attempt: ${message.retryAttempt}');
debugPrint(' Contact has path: ${contact?.hasPath ?? false}');
debugPrint(' Contact has path: ${contact?.routeHasPath ?? false}');
debugPrint(' Used flood fallback: ${message.usedFloodFallback}');
// Decision tree for retry/flood/fail
@@ -1482,18 +1727,31 @@ class MessagesProvider with ChangeNotifier {
debugPrint(
'⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId',
);
final currentIndex = _messages.indexWhere((m) => m.id == messageId);
if (currentIndex == -1) {
return;
}
final currentMessage = _messages[currentIndex];
if (currentMessage.deliveryStatus == MessageDeliveryStatus.delivered) {
return;
}
if (sendMessageCallback != null) {
await sendMessageCallback!(
final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: nextAttempt,
);
if (!queued) {
_markAsPermanentlyFailed(messageId, currentMessage);
}
} else {
debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry',
);
_markAsPermanentlyFailed(messageId, currentMessage);
}
});
@@ -1529,17 +1787,21 @@ class MessagesProvider with ChangeNotifier {
// Send with flood mode (no retry after this)
if (sendMessageCallback != null) {
await sendMessageCallback!(
final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
retryAttempt: 0, // Reset attempt for flood
);
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
} else {
debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood',
);
_markAsPermanentlyFailed(messageId, _messages[index]);
}
_persistMessages();
@@ -1562,17 +1824,95 @@ class MessagesProvider with ChangeNotifier {
if (message.expectedAckTag != null) {
_pendingSentMessages.remove(message.expectedAckTag);
}
_clearAckHistoryForMessage(messageId);
// Clear retry tracking
_retryManager.clearRetry(messageId);
final failedContact = _messageContactMap[messageId];
if (failedContact != null && failedContact.routeHasPath) {
final failureStreak = _retryManager.recordPathFailure(failedContact);
debugPrint(
' Path failure streak for ${failedContact.advName}: $failureStreak',
);
if (failureStreak >= 2 && onDirectPathFailedCallback != null) {
unawaited(
onDirectPathFailedCallback!(
contact: failedContact,
failureStreak: failureStreak,
),
);
}
}
_persistMessages();
notifyListeners();
}
}
/// Reset an existing failed message back into a sending state so a manual
/// retry can reuse the same record instead of appending a duplicate.
bool prepareMessageForRetry(String messageId) {
final index = _messages.indexWhere((m) => m.id == messageId);
if (index == -1) {
debugPrint(
'⚠️ [MessagesProvider] prepareMessageForRetry: Message not found: $messageId',
);
return false;
}
final message = _messages[index];
_timeoutTimers[message.id]?.cancel();
_timeoutTimers.remove(message.id);
if (message.expectedAckTag != null) {
_pendingSentMessages.remove(message.expectedAckTag);
}
_clearAckHistoryForMessage(messageId);
_retryManager.clearRetry(messageId);
_messages[index] = Message(
id: message.id,
messageType: message.messageType,
senderPublicKeyPrefix: message.senderPublicKeyPrefix,
channelIdx: message.channelIdx,
pathLen: message.pathLen,
textType: message.textType,
senderTimestamp: message.senderTimestamp,
text: message.text,
isSarMarker: message.isSarMarker,
sarGpsCoordinates: message.sarGpsCoordinates,
sarNotes: message.sarNotes,
sarCustomEmoji: message.sarCustomEmoji,
sarColorIndex: message.sarColorIndex,
receivedAt: message.receivedAt,
senderName: message.senderName,
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: message.recipientPublicKey,
retryAttempt: 0,
lastRetryAt: DateTime.now(),
usedFloodFallback: false,
isRead: message.isRead,
echoCount: message.echoCount,
firstEchoAt: message.firstEchoAt,
lastEchoSnrRaw: message.lastEchoSnrRaw,
lastEchoRssiDbm: message.lastEchoRssiDbm,
lastEchoAt: message.lastEchoAt,
isDrawing: message.isDrawing,
drawingId: message.drawingId,
groupId: message.groupId,
recipients: message.recipients,
isVoice: message.isVoice,
voiceId: message.voiceId,
);
_persistMessages();
notifyListeners();
return true;
}
/// Resend a failed message
Future<void> resendMessage(String messageId) async {
Future<void> resendMessage(String messageId, {Contact? contact}) async {
final index = _messages.indexWhere((m) => m.id == messageId);
if (index == -1) {
debugPrint(
@@ -1582,9 +1922,9 @@ class MessagesProvider with ChangeNotifier {
}
final message = _messages[index];
final contact = _messageContactMap[messageId];
final resolvedContact = contact ?? _messageContactMap[messageId];
if (contact == null) {
if (resolvedContact == null) {
debugPrint(
'⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId',
);
@@ -1593,32 +1933,29 @@ class MessagesProvider with ChangeNotifier {
debugPrint('🔁 [MessagesProvider] Resending message $messageId');
// Reset retry state
_messages[index] = message.copyWith(
retryAttempt: 0,
usedFloodFallback: false,
deliveryStatus: MessageDeliveryStatus.sending,
lastRetryAt: DateTime.now(),
);
// Clear retry tracking
_retryManager.clearRetry(messageId);
notifyListeners();
_messageContactMap[messageId] = resolvedContact;
final prepared = prepareMessageForRetry(messageId);
if (!prepared) {
return;
}
// Send again
if (sendMessageCallback != null) {
await sendMessageCallback!(
contactPublicKey: contact.publicKey,
final queued = await sendMessageCallback!(
contactPublicKey: resolvedContact.publicKey,
text: message.text,
messageId: messageId,
contact: contact,
contact: resolvedContact,
retryAttempt: 0,
);
if (!queued) {
_markAsPermanentlyFailed(messageId, _messages[index]);
}
} else {
debugPrint(
'⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend',
);
_markAsPermanentlyFailed(messageId, _messages[index]);
}
_persistMessages();
@@ -1631,10 +1968,62 @@ class MessagesProvider with ChangeNotifier {
timer.cancel();
}
_timeoutTimers.clear();
_completedAckHistory.clear();
_messageAckHistory.clear();
_ackHistoryLookup.clear();
// Clear retry manager
_retryManager.clearAll();
super.dispose();
}
void _rememberCompletedAck(int ackCode) {
_completedAckHistory[ackCode] = DateTime.now();
_cleanupCompletedAckHistory();
}
void _cleanupCompletedAckHistory({
Duration maxAge = const Duration(minutes: 15),
}) {
final cutoff = DateTime.now().subtract(maxAge);
final staleAcks = _completedAckHistory.entries
.where((entry) => entry.value.isBefore(cutoff))
.map((entry) => entry.key)
.toList();
for (final ack in staleAcks) {
_completedAckHistory.remove(ack);
}
}
void _clearAckHistoryForMessage(String messageId) {
final ackTags = _messageAckHistory.remove(messageId);
if (ackTags == null) {
return;
}
for (final ack in ackTags) {
_ackHistoryLookup.remove(ack);
}
}
void _cleanupAckHistoryLookup({
Duration maxAge = const Duration(minutes: 15),
}) {
final cutoff = DateTime.now().subtract(maxAge);
final staleAcks = _ackHistoryLookup.entries
.where((entry) => entry.value.$2.isBefore(cutoff))
.map((entry) => entry.key)
.toList();
for (final ack in staleAcks) {
final messageId = _ackHistoryLookup.remove(ack)?.$1;
if (messageId == null) {
continue;
}
final history = _messageAckHistory[messageId];
history?.remove(ack);
if (history != null && history.isEmpty) {
_messageAckHistory.remove(messageId);
}
}
}
}

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import '../l10n/app_localizations.dart';
import '../models/contact.dart';
import '../providers/contacts_provider.dart';
import '../providers/app_provider.dart';
import '../providers/connection_provider.dart';
@@ -119,6 +120,43 @@ class _ContactsTabState extends State<ContactsTab> {
return l10n.daysAgo(diff.inDays);
}
List<Contact> _sortContactsByDistance(List<Contact> contacts) {
final sorted = List<Contact>.from(contacts);
sorted.sort((a, b) {
final distanceA = _distanceFromCurrentPosition(a);
final distanceB = _distanceFromCurrentPosition(b);
if (distanceA != null && distanceB != null) {
final distanceCompare = distanceA.compareTo(distanceB);
if (distanceCompare != 0) return distanceCompare;
} else if (distanceA != null) {
return -1;
} else if (distanceB != null) {
return 1;
}
return b.lastSeenTime.compareTo(a.lastSeenTime);
});
return sorted;
}
double? _distanceFromCurrentPosition(Contact contact) {
final currentPosition = _currentPosition;
final contactLocation = contact.displayLocation;
if (currentPosition == null || contactLocation == null) {
return null;
}
return _calculateDistanceInMeters(
currentPosition.latitude,
currentPosition.longitude,
contactLocation.latitude,
contactLocation.longitude,
);
}
/// Show the add channel dialog
Future<void> _showAddChannelDialog(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
@@ -165,10 +203,12 @@ class _ContactsTabState extends State<ContactsTab> {
return Scaffold(
body: Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final chatContacts = contactsProvider.chatContacts;
final repeaters = contactsProvider.repeaters;
final rooms = contactsProvider.rooms;
final channels = contactsProvider.channels;
final chatContacts = _sortContactsByDistance(
contactsProvider.chatContacts,
);
final repeaters = _sortContactsByDistance(contactsProvider.repeaters);
final rooms = _sortContactsByDistance(contactsProvider.rooms);
final channels = _sortContactsByDistance(contactsProvider.channels);
final pendingAdverts = contactsProvider.pendingAdverts;
// Check if there are any displayable contacts (excluding channels)

View File

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

View File

@@ -12,18 +12,21 @@ import '../providers/contacts_provider.dart';
import '../theme/app_theme.dart';
import 'messages_tab.dart';
import 'contacts_tab.dart';
import 'sensors_tab.dart';
import 'map_tab.dart';
import 'map_management_screen.dart';
import 'settings_screen.dart';
import 'device_config_screen.dart';
import 'packet_log_screen.dart';
import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart';
enum _HomeTab { messages, contacts, map }
enum _HomeTab { messages, contacts, sensors, map }
enum _AdvertMode { flood, direct }
class HomeScreen extends StatefulWidget {
final Function(AppThemeMode) onThemeChanged;
@@ -46,18 +49,22 @@ class HomeScreen extends StatefulWidget {
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin, WidgetsBindingObserver {
late TabController _tabController;
late final AppProvider _appProvider;
int _currentIndex = 0;
bool _isMapFullscreen = false;
bool _showRxTxIndicators = true;
bool _isMapEnabled = true;
bool _isContactsEnabled = true;
bool _isSensorsEnabled = false;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
List<_HomeTab> get _enabledTabs {
return [
_HomeTab.messages,
if (_isContactsEnabled) _HomeTab.contacts,
if (_isSensorsEnabled) _HomeTab.sensors,
if (_isMapEnabled) _HomeTab.map,
];
}
@@ -73,9 +80,15 @@ class _HomeScreenState extends State<HomeScreen>
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_appProvider = context.read<AppProvider>();
_isMapEnabled = _appProvider.isMapEnabled;
_isContactsEnabled = _appProvider.isContactsEnabled;
_isSensorsEnabled = _appProvider.isSensorsEnabled;
_appProvider.addListener(_handleAppProviderChanged);
// Initialize synchronously so first build always has a valid controller.
_initTabController();
_loadTabVisibilityAndInitTabs();
_loadRxTxPreference();
// Show permission dialog after the first frame if needed
@@ -86,51 +99,70 @@ class _HomeScreenState extends State<HomeScreen>
}
}
Future<void> _loadTabVisibilityAndInitTabs() async {
final prefs = await SharedPreferences.getInstance();
final mapEnabled = prefs.getBool('map_enabled') ?? true;
final contactsEnabled = prefs.getBool('contacts_enabled') ?? true;
if (!mounted) return;
if (_isMapEnabled != mapEnabled || _isContactsEnabled != contactsEnabled) {
_updateTabController(
mapEnabled: mapEnabled,
contactsEnabled: contactsEnabled,
);
}
}
void _initTabController() {
_tabController = TabController(length: _enabledTabs.length, vsync: this);
_tabController.addListener(_onTabChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_handleTabActivated(_currentTab);
});
}
void _handleAppProviderChanged() {
if (!mounted) return;
_updateTabController(
mapEnabled: _appProvider.isMapEnabled,
contactsEnabled: _appProvider.isContactsEnabled,
sensorsEnabled: _appProvider.isSensorsEnabled,
);
}
void _onTabChanged() {
final previousTab = _currentTab;
final nextIndex = _tabController.index;
setState(() {
_currentIndex = _tabController.index;
_currentIndex = nextIndex;
if (_currentTab != _HomeTab.map) {
_isMapFullscreen = false;
}
});
final nextTab = _currentTab;
if (previousTab != nextTab) {
_handleTabActivated(nextTab);
}
}
void _updateTabController({
required bool mapEnabled,
required bool contactsEnabled,
required bool sensorsEnabled,
}) {
if (_isMapEnabled == mapEnabled && _isContactsEnabled == contactsEnabled) {
if (_isMapEnabled == mapEnabled &&
_isContactsEnabled == contactsEnabled &&
_isSensorsEnabled == sensorsEnabled) {
return;
}
final oldTabs = _enabledTabs;
final oldIndex = _tabController.index;
final oldIndex = oldTabs.isEmpty
? 0
: _tabController.index.clamp(0, oldTabs.length - 1);
final oldTab = oldTabs[oldIndex];
final oldController = _tabController;
oldController.removeListener(_onTabChanged);
oldController.dispose();
// Update state
_isMapEnabled = mapEnabled;
_isContactsEnabled = contactsEnabled;
_isSensorsEnabled = sensorsEnabled;
if (!_isMapEnabled) {
_isMapFullscreen = false;
}
final newTabs = _enabledTabs;
final newIndex = newTabs.indexOf(oldTab);
@@ -143,11 +175,7 @@ class _HomeScreenState extends State<HomeScreen>
_tabController.index = _currentIndex;
setState(() {});
// Dispose old controller after widgets have rebound to the new controller.
WidgetsBinding.instance.addPostFrameCallback((_) {
oldController.dispose();
});
_handleTabActivated(_currentTab);
}
void _navigateToTab(_HomeTab tab) {
@@ -157,6 +185,40 @@ class _HomeScreenState extends State<HomeScreen>
}
}
void _handleTabActivated(_HomeTab tab) {
_syncFastLocationUiState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
switch (tab) {
case _HomeTab.messages:
context.read<MessagesProvider>().markAllAsRead();
break;
case _HomeTab.contacts:
context.read<ContactsProvider>().markAllAsViewed();
break;
case _HomeTab.sensors:
break;
case _HomeTab.map:
break;
}
});
}
void _syncFastLocationUiState() {
final isActiveTab =
_currentTab == _HomeTab.map || _currentTab == _HomeTab.messages;
_appProvider.setFastLocationUiActive(
_lifecycleState == AppLifecycleState.resumed && isActiveTab,
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state;
_syncFastLocationUiState();
}
Future<void> _loadRxTxPreference() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
@@ -168,6 +230,9 @@ class _HomeScreenState extends State<HomeScreen>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_appProvider.setFastLocationUiActive(false);
_appProvider.removeListener(_handleAppProviderChanged);
_tabController.removeListener(_onTabChanged);
_tabController.dispose();
super.dispose();
@@ -201,7 +266,187 @@ class _HomeScreenState extends State<HomeScreen>
);
}
Future<void> _advertiseDevice(BuildContext context) async {
Future<void> _triggerAdvertFeedback() async {
final platform = Theme.of(context).platform;
try {
if (platform == TargetPlatform.iOS) {
await HapticFeedback.lightImpact();
await Future.delayed(const Duration(milliseconds: 50));
await HapticFeedback.lightImpact();
} else {
if (await Vibration.hasVibrator()) {
await Vibration.vibrate(duration: 50);
} else {
await HapticFeedback.mediumImpact();
}
}
} catch (e) {
debugPrint('Haptic feedback error: $e');
await HapticFeedback.vibrate();
}
}
Future<_AdvertMode?> _showAdvertModeSheet(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final theme = Theme.of(context);
return showModalBottomSheet<_AdvertMode>(
context: context,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Advert mode',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
Text(
'Choose how far this announcement should travel.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.hub_rounded,
color: theme.colorScheme.onPrimaryContainer,
),
),
title: Text(l10n.flood),
subtitle: const Text('Relay through repeaters across the mesh'),
trailing: const Icon(Icons.chevron_right_rounded),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
onTap: () => Navigator.of(context).pop(_AdvertMode.flood),
),
const SizedBox(height: 8),
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: theme.colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(14),
),
child: Icon(
Icons.near_me_rounded,
color: theme.colorScheme.onSecondaryContainer,
),
),
title: Text(l10n.direct),
subtitle: const Text('Nearby only, without repeater flooding'),
trailing: const Icon(Icons.chevron_right_rounded),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
onTap: () => Navigator.of(context).pop(_AdvertMode.direct),
),
],
),
),
),
);
}
Widget _buildActivityBadge({
required String label,
required int count,
required bool isActive,
required Color activeColor,
}) {
final color = isActive ? activeColor : Colors.grey;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(width: 6),
Text(
'$label:$count',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
Widget _buildCompactActivityIndicator({
required bool rxActive,
required bool txActive,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: rxActive ? Colors.green : Colors.grey,
),
),
const SizedBox(width: 6),
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: txActive ? Colors.blue : Colors.grey,
),
),
],
),
);
}
Future<void> _advertiseDevice(
BuildContext context, {
bool floodMode = true,
}) async {
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
@@ -281,8 +526,14 @@ class _HomeScreenState extends State<HomeScreen>
// Small delay to ensure the lat/lon is set
await Future.delayed(const Duration(milliseconds: 100));
// Send flood advertisement
await connectionProvider.sendSelfAdvert(floodMode: true);
await connectionProvider.sendSelfAdvert(floodMode: floodMode);
if (context.mounted) {
ToastLogger.success(
context,
floodMode ? 'Flood advert sent' : 'Direct advert sent',
);
}
} catch (e) {
debugPrint('❌ Failed to advertise device: $e');
if (context.mounted) {
@@ -312,18 +563,7 @@ class _HomeScreenState extends State<HomeScreen>
messagesProvider.setLocalizations(localizations);
}
// Check if tab visibility settings changed and update tab controller
final appProvider = context.watch<AppProvider>();
if (_isMapEnabled != appProvider.isMapEnabled ||
_isContactsEnabled != appProvider.isContactsEnabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_updateTabController(
mapEnabled: appProvider.isMapEnabled,
contactsEnabled: appProvider.isContactsEnabled,
);
});
}
context.watch<AppProvider>();
final enabledTabs = _enabledTabs;
final isMapTabActive = _currentTab == _HomeTab.map;
@@ -334,6 +574,8 @@ class _HomeScreenState extends State<HomeScreen>
appBar: shouldHideUI
? null
: AppBar(
toolbarHeight: 64,
titleSpacing: 8,
title: _buildCompactStatusBar(),
actions: [
Consumer<ConnectionProvider>(
@@ -360,22 +602,18 @@ class _HomeScreenState extends State<HomeScreen>
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.map),
const Icon(Icons.radar),
const SizedBox(width: 8),
Text(AppLocalizations.of(context)!.mapManagement),
const Text('Spectrum Scan'),
],
),
onTap: () {
// Capture context-dependent objects before async gap
final navigator = Navigator.of(context);
final appProvider = context.read<AppProvider>();
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) => MapManagementScreen(
tileCacheService: appProvider.tileCacheService,
),
builder: (context) => const SpectrumScanScreen(),
),
);
});
@@ -429,6 +667,8 @@ class _HomeScreenState extends State<HomeScreen>
? () => _navigateToTab(_HomeTab.map)
: null,
);
case _HomeTab.sensors:
return const SensorsTab();
case _HomeTab.map:
return MapTab(
onFullscreenChanged: (isFullscreen) {
@@ -460,6 +700,13 @@ class _HomeScreenState extends State<HomeScreen>
),
child: TabBar(
controller: _tabController,
onTap: (index) {
final tabs = _enabledTabs;
if (index < 0 || index >= tabs.length) {
return;
}
_handleTabActivated(tabs[index]);
},
tabs: enabledTabs.map((tab) {
switch (tab) {
case _HomeTab.messages:
@@ -483,6 +730,11 @@ class _HomeScreenState extends State<HomeScreen>
icon: const Icon(Icons.map),
text: AppLocalizations.of(context)!.map,
);
case _HomeTab.sensors:
return const Tab(
icon: Icon(Icons.sensors),
text: 'Sensors',
);
}
}).toList(),
),
@@ -498,7 +750,6 @@ class _HomeScreenState extends State<HomeScreen>
final deviceInfo = provider.deviceInfo;
final isConnected = deviceInfo.isConnected;
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
final isBleConnected = isConnected && !isTcpConnected;
if (!isConnected) {
// Disconnected state: show connect button
@@ -560,241 +811,244 @@ class _HomeScreenState extends State<HomeScreen>
);
}
// Connected state: LEFT | CENTER | RIGHT layout
return Row(
children: [
// LEFT: Name + BT/Battery + Cog
Expanded(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
final theme = Theme.of(context);
final subtitleColor = theme.colorScheme.onSurfaceVariant;
final signalColor = isTcpConnected
? Colors.green
: (deviceInfo.signalRssi != null
? BatteryDisplayHelper.getSignalColor(deviceInfo.signalRssi!)
: Colors.grey);
return LayoutBuilder(
builder: (context, constraints) {
final isTight = constraints.maxWidth < 360;
return Row(
children: [
Flexible(
fit: FlexFit.loose,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(22),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
deviceInfo.selfName ??
AppLocalizations.of(context)!.appTitle,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
Flexible(
fit: FlexFit.loose,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
deviceInfo.selfName ??
AppLocalizations.of(context)!.appTitle,
style:
(isTight
? theme.textTheme.titleSmall
: theme.textTheme.titleMedium)
?.copyWith(fontWeight: FontWeight.w700),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isTcpConnected
? Icons.wifi_rounded
: Icons.bluetooth_connected_rounded,
size: 13,
color: signalColor,
),
if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4),
SizedBox(
width: 28,
child: Text(
'${deviceInfo.signalRssi}',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: signalColor,
),
maxLines: 1,
),
),
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
size: 13,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
const SizedBox(width: 4),
SizedBox(
width: 30,
child: Text(
'${deviceInfo.batteryPercent!.round()}%',
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
),
],
],
),
],
),
overflow: TextOverflow.ellipsis,
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isTcpConnected
? Icons.wifi
: Icons.bluetooth_connected,
color: isTcpConnected
? Colors.green
: (deviceInfo.signalRssi != null
? BatteryDisplayHelper.getSignalColor(
deviceInfo.signalRssi!,
)
: Colors.grey),
size: 13,
const SizedBox(width: 4),
IconButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const DeviceConfigScreen(),
),
);
},
onLongPress: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PacketLogScreen(
bleService: provider.bleService,
),
),
);
},
tooltip: AppLocalizations.of(context)!.settings,
icon: const Icon(Icons.tune_rounded),
color: subtitleColor,
style: IconButton.styleFrom(
backgroundColor: theme.colorScheme.surface,
foregroundColor: subtitleColor,
minimumSize: Size.square(isTight ? 38 : 40),
padding: EdgeInsets.zero,
),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () async {
await _triggerAdvertFeedback();
if (!mounted || !context.mounted) return;
await _advertiseDevice(context);
},
onLongPress: () async {
await _triggerAdvertFeedback();
if (!mounted || !context.mounted) return;
final mode = await _showAdvertModeSheet(context);
if (!mounted || !context.mounted || mode == null) {
return;
}
await _advertiseDevice(
context,
floodMode: mode == _AdvertMode.flood,
);
},
child: Container(
width: isTight ? 38 : 40,
height: isTight ? 38 : 40,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
theme.colorScheme.primary,
theme.colorScheme.primary.withValues(
alpha: 0.78,
),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
if (isBleConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 3),
Text(
'${deviceInfo.signalRssi}',
style: TextStyle(
fontSize: 11,
color: BatteryDisplayHelper.getSignalColor(
deviceInfo.signalRssi!,
),
),
),
],
if (isTcpConnected) ...[
const SizedBox(width: 3),
Text(
'WiFi',
style: const TextStyle(
fontSize: 11,
color: Colors.green,
),
),
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
color: BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
size: 13,
),
const SizedBox(width: 3),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 11,
color: BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
],
],
child: Icon(
Icons.campaign_rounded,
color: Colors.white,
size: isTight ? 18 : 20,
),
),
),
],
),
),
// Settings cog - hidden in simple mode
if (!context.watch<AppProvider>().isSimpleMode) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const DeviceConfigScreen(),
),
);
},
onLongPress: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => PacketLogScreen(
bleService: provider.bleService,
),
SizedBox(width: isTight ? 8 : 12),
if (_showRxTxIndicators)
GestureDetector(
onLongPress: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
PacketLogScreen(bleService: provider.bleService),
),
);
},
child: isTight
? _buildCompactActivityIndicator(
rxActive: provider.rxActivity,
txActive: provider.txActivity,
)
: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh
.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(20),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_buildActivityBadge(
label: 'RX',
count: provider.rxPacketCount,
isActive: provider.rxActivity,
activeColor: Colors.green,
),
const SizedBox(height: 6),
_buildActivityBadge(
label: 'TX',
count: provider.txPacketCount,
isActive: provider.txActivity,
activeColor: Colors.blue,
),
],
),
),
);
},
child: Container(
width: 32,
height: 32,
alignment: Alignment.center,
child: const Icon(Icons.settings, size: 18),
),
),
],
],
),
),
// CENTER: Broadcast button
const SizedBox(width: 8),
FilledButton(
onPressed: () async {
// Capture platform before async operations
final platform = Theme.of(context).platform;
// iOS: Use haptic feedback (always works)
// Android: Try vibration package for better control
try {
if (platform == TargetPlatform.iOS) {
// iOS: Try multiple haptic types for reliability
await HapticFeedback.lightImpact();
await Future.delayed(const Duration(milliseconds: 50));
await HapticFeedback.lightImpact();
} else {
// Android vibration
if (await Vibration.hasVibrator()) {
await Vibration.vibrate(duration: 50);
} else {
await HapticFeedback.mediumImpact();
}
}
} catch (e) {
// Fallback if anything fails
debugPrint('Haptic feedback error: $e');
await HapticFeedback.vibrate();
}
if (!mounted) return;
if (!context.mounted) return;
_advertiseDevice(context);
},
style: FilledButton.styleFrom(
backgroundColor: Colors.blue.shade700,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(10),
minimumSize: const Size(40, 40),
shape: const CircleBorder(),
),
child: const Icon(Icons.campaign, size: 20),
),
const SizedBox(width: 8),
// RIGHT: RX/TX indicators
if (_showRxTxIndicators)
GestureDetector(
onLongPress: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
PacketLogScreen(bleService: provider.bleService),
),
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: provider.rxActivity
? Colors.green
: Colors.grey.withValues(alpha: 0.3),
),
),
const SizedBox(width: 3),
Text(
'RX:${provider.rxPacketCount}',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
],
),
const SizedBox(height: 3),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: provider.txActivity
? Colors.blue
: Colors.grey.withValues(alpha: 0.3),
),
),
const SizedBox(width: 3),
Text(
'TX:${provider.txPacketCount}',
style: const TextStyle(
fontSize: 10,
color: Colors.grey,
),
),
],
),
],
),
)
else
const SizedBox(
width: 52,
), // Placeholder to maintain layout balance
],
)
else
SizedBox(width: isTight ? 24 : 74),
],
);
},
);
},
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:math' as math;
@@ -18,7 +19,9 @@ import '../models/message.dart';
import '../models/contact.dart';
import '../widgets/messages/sar_update_sheet.dart';
import '../widgets/messages/recipient_selector_sheet.dart';
import '../widgets/messages/message_bubble.dart';
import '../widgets/messages/messages_composer.dart';
import '../widgets/messages/messages_content.dart';
import '../widgets/common/contact_avatar.dart';
import '../services/message_destination_preferences.dart';
import '../services/voice_bitrate_preferences.dart';
import '../services/voice_recorder_service.dart';
@@ -44,11 +47,14 @@ class MessagesTab extends StatefulWidget {
}
class _MessagesTabState extends State<MessagesTab> {
static const int _maxContactMessageBytes = 156;
static const int _maxChannelMessageBytes = 127;
static const double _composerOverlayHeight = 148;
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
final ScrollController _scrollController = ScrollController();
int _characterCount = 0;
static const int _maxCharacters = 160;
int _messageByteCount = 0;
String? _highlightedMessageId;
Timer? _highlightTimer; // Timer for clearing message highlight
@@ -166,10 +172,44 @@ class _MessagesTabState extends State<MessagesTab> {
void _updateCharacterCount() {
setState(() {
_characterCount = _textController.text.length;
_messageByteCount = utf8.encode(_textController.text).length;
});
}
int get _maxMessageBytes =>
_destinationType == MessageDestinationPreferences.destinationTypeChannel
? _maxChannelMessageBytes
: _maxContactMessageBytes;
TextInputFormatter get _messageByteLimiter =>
TextInputFormatter.withFunction((oldValue, newValue) {
if (utf8.encode(newValue.text).length <= _maxMessageBytes) {
return newValue;
}
return oldValue;
});
void _enforceMessageByteLimit() {
final currentText = _textController.text;
if (utf8.encode(currentText).length <= _maxMessageBytes) {
_updateCharacterCount();
return;
}
var truncated = currentText;
while (truncated.isNotEmpty &&
utf8.encode(truncated).length > _maxMessageBytes) {
truncated = truncated.substring(0, truncated.length - 1);
}
_textController.value = _textController.value.copyWith(
text: truncated,
selection: TextSelection.collapsed(offset: truncated.length),
composing: TextRange.empty,
);
_updateCharacterCount();
}
/// Load saved message destination from preferences
Future<void> _loadSavedDestination() async {
final savedDestination =
@@ -211,6 +251,8 @@ class _MessagesTabState extends State<MessagesTab> {
await MessageDestinationPreferences.clearDestination();
}
}
_enforceMessageByteLimit();
}
/// Show recipient selector bottom sheet
@@ -250,6 +292,8 @@ class _MessagesTabState extends State<MessagesTab> {
_selectedRecipient = recipient;
});
_enforceMessageByteLimit();
// Save to preferences
await MessageDestinationPreferences.setDestination(
type,
@@ -273,73 +317,16 @@ class _MessagesTabState extends State<MessagesTab> {
}
}
/// Get tooltip for destination button
String _getDestinationTooltip() {
String _getDestinationLabel() {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel &&
_selectedRecipient != null) {
final channelName = _selectedRecipient!.getLocalizedDisplayName(context);
return '$channelName (tap to change)';
} else if (_selectedRecipient != null) {
final recipientName = _selectedRecipient!.displayName;
return '$recipientName (tap to change)';
return _selectedRecipient!.getLocalizedDisplayName(context);
}
return 'Select recipient';
}
String _getCurrentScopeLabel() {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeChannel) {
final channelName =
_selectedRecipient?.getLocalizedDisplayName(context) ??
AppLocalizations.of(context)!.publicChannel;
return 'Channel > $channelName';
if (_selectedRecipient != null) {
return _selectedRecipient!.displayName;
}
if (_destinationType == MessageDestinationPreferences.destinationTypeRoom) {
final roomName =
_selectedRecipient?.displayName ??
AppLocalizations.of(context)!.messages;
return 'Room > $roomName';
}
final contactName =
_selectedRecipient?.displayName ??
AppLocalizations.of(context)!.messages;
return 'Direct > $contactName';
}
Widget _buildScopeIndicator() {
final theme = Theme.of(context);
return SizedBox(
width: double.infinity,
child: Align(
alignment: Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.visibility_outlined,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6),
Flexible(
child: Text(
'View: ${_getCurrentScopeLabel()}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
),
),
);
return 'Public Channel';
}
Future<void> _sendMessage() async {
@@ -426,7 +413,7 @@ class _MessagesTabState extends State<MessagesTab> {
);
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
// Send to selected channel
await connectionProvider.sendChannelMessage(
@@ -468,7 +455,7 @@ class _MessagesTabState extends State<MessagesTab> {
);
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
messagesProvider.addSentMessage(sentMessage, contact: _selectedRecipient);
// Send message to selected recipient
final sentSuccessfully = await connectionProvider.sendTextMessage(
@@ -588,9 +575,9 @@ class _MessagesTabState extends State<MessagesTab> {
if (_destinationType ==
MessageDestinationPreferences.destinationTypeContact &&
_selectedRecipient != null &&
_selectedRecipient!.outPathLen >= 0) {
_selectedRecipient!.routeHasPath) {
imageDataBytesPerFragment = safeImageDataBytesForPath(
_selectedRecipient!.outPathLen,
_selectedRecipient!.routeHopCount,
);
}
@@ -614,11 +601,6 @@ class _MessagesTabState extends State<MessagesTab> {
ToastLogger.error(context, 'Device key unavailable');
return;
}
final senderKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final envelope = ImageEnvelope(
sessionId: sessionId,
format: ImageFormat.avif,
@@ -626,8 +608,6 @@ class _MessagesTabState extends State<MessagesTab> {
width: result.width,
height: result.height,
sizeBytes: compressed.length,
senderKey6: senderKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
if (!mounted) return;
@@ -659,7 +639,7 @@ class _MessagesTabState extends State<MessagesTab> {
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: isChannel ? null : recipient?.publicKey,
);
messagesProvider.addSentMessage(placeholder);
messagesProvider.addSentMessage(placeholder, contact: recipient);
// Send IE1 envelope via normal message path.
final envelopeText = envelope.encode();
@@ -928,9 +908,6 @@ class _MessagesTabState extends State<MessagesTab> {
return;
}
final senderKey6 = senderPublicKeyPrefix
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final durationMs = encodedPackets.fold<int>(
0,
(sum, p) => sum + p.durationMs,
@@ -940,9 +917,7 @@ class _MessagesTabState extends State<MessagesTab> {
mode: mode,
total: encodedPackets.length,
durationMs: durationMs,
senderKey6: senderKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 1,
version: 3,
);
final envelopeText = envelope.encodeText();
@@ -964,7 +939,7 @@ class _MessagesTabState extends State<MessagesTab> {
channelIdx: channelIdx,
recipientPublicKey: isChannel ? null : recipient?.publicKey,
);
messagesProvider.addSentMessage(sentMsg);
messagesProvider.addSentMessage(sentMsg, contact: recipient);
try {
if (isChannel) {
@@ -1114,6 +1089,16 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
Future<void> _runAfterSheetDismissal(
BuildContext sheetContext,
Future<void> Function() action,
) async {
Navigator.pop(sheetContext);
await Future<void>.delayed(const Duration(milliseconds: 180));
if (!mounted) return;
await action();
}
void _showComposerActions() {
showModalBottomSheet(
context: context,
@@ -1125,9 +1110,10 @@ class _MessagesTabState extends State<MessagesTab> {
ListTile(
leading: const Icon(Icons.add_location_alt),
title: Text(AppLocalizations.of(context)!.sendSarMarker),
onTap: () {
Navigator.pop(sheetContext);
_showSarDialog();
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
_showSarDialog();
});
},
),
if (_voiceSupported)
@@ -1137,13 +1123,14 @@ class _MessagesTabState extends State<MessagesTab> {
title: Text(_isRecording ? 'Stop recording' : 'Record voice'),
onTap: _isSendingVoice
? null
: () {
Navigator.pop(sheetContext);
if (_isRecording) {
_stopAndSendVoice();
} else {
_startVoiceRecording();
}
: () async {
await _runAfterSheetDismissal(sheetContext, () async {
if (_isRecording) {
await _stopAndSendVoice();
} else {
await _startVoiceRecording();
}
});
},
),
ListTile(
@@ -1152,9 +1139,10 @@ class _MessagesTabState extends State<MessagesTab> {
title: const Text('Send image from gallery'),
onTap: _isSendingImage
? null
: () {
Navigator.pop(sheetContext);
_pickAndSendImage(source: ImageSource.gallery);
: () async {
await _runAfterSheetDismissal(sheetContext, () async {
await _pickAndSendImage(source: ImageSource.gallery);
});
},
),
ListTile(
@@ -1163,18 +1151,20 @@ class _MessagesTabState extends State<MessagesTab> {
title: const Text('Take photo'),
onTap: _isSendingImage
? null
: () {
Navigator.pop(sheetContext);
_pickAndSendImage(source: ImageSource.camera);
: () async {
await _runAfterSheetDismissal(sheetContext, () async {
await _pickAndSendImage(source: ImageSource.camera);
});
},
),
ListTile(
leading: const Icon(Icons.grid_3x3),
title: const Text('Start Tic-Tac-Toe'),
subtitle: const Text('DM only'),
onTap: () {
Navigator.pop(sheetContext);
_startTicTacToeGame();
onTap: () async {
await _runAfterSheetDismissal(sheetContext, () async {
await _startTicTacToeGame();
});
},
),
],
@@ -1184,6 +1174,23 @@ class _MessagesTabState extends State<MessagesTab> {
);
}
Widget _buildDestinationAvatar(BuildContext context) {
final recipient = _selectedRecipient;
if (recipient != null) {
return ContactAvatar(
contact: recipient,
radius: 14,
displayName: _getDestinationLabel(),
);
}
return Icon(
_getDestinationIcon(),
size: 17,
color: Theme.of(context).colorScheme.onSurfaceVariant,
);
}
Future<void> _sendSarMessage(
String emoji,
String name,
@@ -1370,9 +1377,6 @@ class _MessagesTabState extends State<MessagesTab> {
// SAR marker data is automatically added by SarMessageParser.enhanceMessage in MessagesProvider
);
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage);
// Look up the room contact for path logging
final contactsProvider = context.read<ContactsProvider>();
final roomContact = contactsProvider.contacts.where((c) {
@@ -1380,6 +1384,9 @@ class _MessagesTabState extends State<MessagesTab> {
c.publicKey.matches(roomPublicKey);
}).firstOrNull;
// Add to messages list with "sending" status
messagesProvider.addSentMessage(sentMessage, contact: roomContact);
// Send SAR message to selected room (persisted and immutable)
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: roomPublicKey!,
@@ -1429,10 +1436,6 @@ class _MessagesTabState extends State<MessagesTab> {
// Get all recent messages
final allMessages = messagesProvider.getRecentMessages(count: 100);
// Get simple mode setting from AppProvider
final appProvider = context.read<AppProvider>();
final isSimpleMode = appProvider.isSimpleMode;
List<Message> filteredMessages;
// If channel destination is selected, filter by selected channel.
@@ -1485,14 +1488,31 @@ class _MessagesTabState extends State<MessagesTab> {
filteredMessages = allMessages;
}
// In simple mode, filter out system messages (toast logs)
if (isSimpleMode) {
filteredMessages = filteredMessages
.where((message) => !message.isSystemMessage)
.toList();
return filteredMessages
.where((message) => !message.isSystemMessage)
.toList();
}
void _handleMessageTap(Message message) {
if (widget.onNavigateToMap == null) return;
if (message.isSarMarker && message.sarGpsCoordinates != null) {
final mapProvider = context.read<MapProvider>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
widget.onNavigateToMap?.call();
return;
}
return filteredMessages;
if (message.isDrawing && message.drawingId != null) {
debugPrint('🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}');
final mapProvider = context.read<MapProvider>();
final drawingProvider = context.read<DrawingProvider>();
mapProvider.navigateToDrawing(message.drawingId!, drawingProvider);
widget.onNavigateToMap?.call();
}
}
@override
@@ -1500,295 +1520,51 @@ class _MessagesTabState extends State<MessagesTab> {
return Consumer<MessagesProvider>(
builder: (context, messagesProvider, child) {
final messages = _getFilteredMessages(messagesProvider);
final bottomInset = MediaQuery.of(context).viewPadding.bottom;
final composerBottomPadding = bottomInset > 0 ? 2.0 : 10.0;
return Column(
children: [
// Messages list with pull-to-refresh
Expanded(
child: RefreshIndicator(
onRefresh: _handleRefresh,
child: messages.isEmpty
? LayoutBuilder(
builder: (context, constraints) =>
SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.message_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(
context,
)!.noMessagesYet,
style: Theme.of(
context,
).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(
context,
)!.pullDownToSync,
style: Theme.of(
context,
).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
)
: ListView.builder(
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.all(8),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
final isHighlighted =
message.id == _highlightedMessageId;
return MessageBubble(
key: ValueKey(message.id),
message: message,
isHighlighted: isHighlighted,
onNavigateToMap: widget.onNavigateToMap,
onTap:
widget.onNavigateToMap != null &&
message.isSarMarker &&
message.sarGpsCoordinates != null
? () {
final mapProvider = context
.read<MapProvider>();
mapProvider.navigateToLocation(
location: message.sarGpsCoordinates!,
zoom: 15.0,
);
widget.onNavigateToMap?.call();
}
: widget.onNavigateToMap != null &&
message.isDrawing &&
message.drawingId != null
? () {
debugPrint(
'🗺️ [MessagesTab] Drawing tapped! ID: ${message.drawingId}',
);
final mapProvider = context
.read<MapProvider>();
final drawingProvider = context
.read<DrawingProvider>();
mapProvider.navigateToDrawing(
message.drawingId!,
drawingProvider,
);
widget.onNavigateToMap?.call();
}
: null,
);
},
),
),
),
// Message input area
Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: Stack(
children: [
Positioned.fill(
child: MessagesContent(
messages: messages,
scrollController: _scrollController,
highlightedMessageId: _highlightedMessageId,
bottomContentPadding:
_composerOverlayHeight + composerBottomPadding,
onRefresh: _handleRefresh,
onNavigateToMap: widget.onNavigateToMap,
onMessageTap: _handleMessageTap,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Quick actions (+) button
IconButton(
icon: Icon(_isRecording ? Icons.stop : Icons.add),
tooltip: _isRecording
? 'Stop recording'
: 'More actions',
onPressed: _isRecording
? _stopAndSendVoice
: _showComposerActions,
style: IconButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
foregroundColor: _isRecording
? Colors.red
: Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 4),
// Destination switcher button
IconButton(
icon: Icon(_getDestinationIcon()),
tooltip: _getDestinationTooltip(),
onPressed: _showRecipientSelector,
style: IconButton.styleFrom(
backgroundColor:
_destinationType ==
MessageDestinationPreferences
.destinationTypeChannel
? Theme.of(
context,
).colorScheme.surfaceContainerHighest
: Theme.of(
context,
).colorScheme.secondaryContainer,
foregroundColor:
_destinationType ==
MessageDestinationPreferences
.destinationTypeChannel
? Theme.of(context).colorScheme.onSurface
: Theme.of(
context,
).colorScheme.onSecondaryContainer,
),
),
const SizedBox(width: 4),
// Scope label + text field share the same alignment
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: 4,
right: 4,
bottom: 6,
),
child: _buildScopeIndicator(),
),
TextField(
controller: _textController,
focusNode: _focusNode,
maxLength: _maxCharacters,
maxLines: null,
maxLengthEnforcement:
MaxLengthEnforcement.enforced,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: AppLocalizations.of(
context,
)!.typeYourMessage,
hintStyle: const TextStyle(fontSize: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
isDense: true,
counterText: _characterCount >= 150
? '$_characterCount/$_maxCharacters'
: '',
counterStyle: TextStyle(
fontSize: 10,
color:
_characterCount > _maxCharacters * 0.9
? Colors.orange
: Theme.of(
context,
).textTheme.bodySmall?.color,
),
suffixIcon: GestureDetector(
onLongPressStart:
(_voiceSupported && !_isSendingVoice)
? (_) => _startVoiceRecording()
: null,
onLongPressEnd:
(_voiceSupported && _isRecording)
? (_) => _stopAndSendVoice()
: null,
onLongPressCancel:
(_voiceSupported && _isRecording)
? () => _stopAndSendVoice()
: null,
child: IconButton(
icon: _isSendingVoice
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(
_isRecording
? Icons.mic
: Icons.send_rounded,
size: 22,
color: _isRecording
? Colors.red
: (_textController.text
.trim()
.isEmpty
? Theme.of(
context,
).disabledColor
: Theme.of(context)
.colorScheme
.primary),
),
onPressed:
_isRecording ||
_isSendingVoice ||
_textController.text
.trim()
.isEmpty
? null
: _sendMessage,
tooltip: _isRecording
? 'Recording... release to send voice'
: (_isSendingVoice
? 'Sending voice...'
: _voiceSupported
? 'Send (long press to record voice)'
: 'Send'),
),
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
],
),
),
],
),
),
],
Positioned(
left: 0,
right: 0,
bottom: 0,
child: MessagesComposer(
textController: _textController,
focusNode: _focusNode,
messageByteLimiter: _messageByteLimiter,
messageByteCount: _messageByteCount,
maxMessageBytes: _maxMessageBytes,
isRecording: _isRecording,
isSendingVoice: _isSendingVoice,
voiceSupported: _voiceSupported,
bottomPadding: composerBottomPadding,
destinationLabel: _getDestinationLabel(),
destinationAvatar: _buildDestinationAvatar(context),
onShowComposerActions: _showComposerActions,
onShowRecipientSelector: _showRecipientSelector,
onStartVoiceRecording: _startVoiceRecording,
onStopAndSendVoice: _stopAndSendVoice,
onSendMessage: _sendMessage,
),
),
),
],
],
),
);
},
);

View File

@@ -1,10 +1,14 @@
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 '../utils/log_rx_route_decoder.dart';
class PacketLogScreen extends StatefulWidget {
final MeshCoreBleService bleService;
@@ -456,6 +460,26 @@ class _PacketLogCard extends StatelessWidget {
final isRx = log.direction == PacketDirection.rx;
final directionColor = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final decodedRoute = LogRxRouteDecoder.decode(log.rawData);
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
final resolvedPath = decodedRoute?.pathHashes
.map(
(hash) => LogRxRouteDecoder.resolveHash(
hash,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
final originalSender = resolvedPath != null && resolvedPath.isNotEmpty
? resolvedPath.first
: null;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -580,6 +604,14 @@ class _PacketLogCard extends StatelessWidget {
),
),
],
if (isRx && decodedRoute != null) ...[
const SizedBox(height: 12),
_RouteSection(
route: decodedRoute,
path: resolvedPath ?? const [],
originalSender: originalSender,
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
@@ -723,6 +755,162 @@ class _PacketLogCard extends StatelessWidget {
}
}
class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path;
final ResolvedNodeHash? originalSender;
const _RouteSection({
required this.route,
required this.path,
required this.originalSender,
});
@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.pathHashes.length}',
),
if (originalSender != null)
_FactCard(
icon: Icons.person_pin_circle,
label: 'Original sender',
value: _nodeLabel(originalSender!),
),
],
),
const SizedBox(height: 12),
if (path.isEmpty)
Text(
'Direct packet, no hop path attached.',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (var i = 0; i < path.length; i++) ...[
_RouteHopChip(index: i + 1, node: path[i]),
if (i < path.length - 1)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: Icon(Icons.arrow_right_alt, size: 16),
),
],
],
),
],
),
);
}
static String _payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'REQ';
case 0x01:
return 'RESP';
case 0x02:
return 'TXT';
case 0x03:
return 'ACK';
case 0x04:
return 'ADVERT';
case 0x05:
return 'GRP_TXT';
case 0x06:
return 'GRP_DATA';
case 0x07:
return 'ANON_REQ';
case 0x08:
return 'PATH';
case 0x09:
return 'TRACE';
case 0x0A:
return 'MULTIPART';
case 0x0B:
return 'CONTROL';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
}
}
static String _nodeLabel(ResolvedNodeHash node) {
if (node.isOwnNode) {
return '${node.label} (${node.hexLabel})';
}
if (node.matchCount == 0) {
return node.hexLabel;
}
if (node.isUniqueMatch) {
return '${node.label} (${node.hexLabel})';
}
return '${node.label} (${node.hexLabel}, ${node.matchCount} matches)';
}
}
class _RouteHopChip extends StatelessWidget {
final int index;
final ResolvedNodeHash node;
const _RouteHopChip({required this.index, required this.node});
@override
Widget build(BuildContext context) {
final color = node.isOwnNode
? Colors.blue
: node.isUniqueMatch
? Colors.green
: Colors.orange;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Text(
'$index. ${_RouteSection._nodeLabel(node)}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
);
}
}
class _FactCard extends StatelessWidget {
final IconData icon;
final String label;

1107
lib/screens/sensors_tab.dart Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -15,6 +15,8 @@ class LocalePreferences {
Locale('fr'), // French
Locale('it'), // Italian
Locale('el'), // Greek
Locale('ru'), // Russian
Locale('zh'), // Chinese
];
/// Get the saved locale or return null to use system locale
@@ -64,6 +66,10 @@ class LocalePreferences {
return 'Italiano';
case 'el':
return 'Greek';
case 'ru':
return 'Русский';
case 'zh':
return '简体中文';
default:
return locale.languageCode;
}
@@ -88,6 +94,10 @@ class LocalePreferences {
return 'Italiano';
case 'el':
return 'Ελληνικά';
case 'ru':
return 'Русский';
case 'zh':
return '简体中文';
default:
return locale.languageCode;
}

View File

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

View File

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

View File

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

View File

@@ -31,23 +31,29 @@ class MeshMapNode {
}
class MeshMapNodesService {
static const String _nodesEndpoint = 'https://api.meshcore.nz/api/v1/map/nodes';
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 Duration traceTimeout = Duration(seconds: 30);
static List<MeshMapNode>? _cachedNodes;
static DateTime? _cachedAt;
static Future<List<MeshMapNode>> fetchNodes({bool forceRefresh = false}) async {
static Future<List<MeshMapNode>> fetchNodes({
bool forceRefresh = false,
Duration cacheTtl = _cacheTtl,
}) async {
final now = DateTime.now();
if (!forceRefresh &&
_cachedNodes != null &&
_cachedAt != null &&
now.difference(_cachedAt!) < _cacheTtl) {
now.difference(_cachedAt!) < cacheTtl) {
return _cachedNodes!;
}
final response = await http
.get(Uri.parse(_nodesEndpoint))
.timeout(const Duration(seconds: 12));
.timeout(traceTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Map nodes API returned ${response.statusCode}');
}
@@ -58,7 +64,9 @@ class MeshMapNodesService {
final nodes = nodesRaw
.whereType<Map<String, dynamic>>()
.map(MeshMapNode.fromJson)
.where((n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0)
.where(
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
)
.toList();
_cachedNodes = nodes;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,129 @@
import 'dart:typed_data';
import '../models/contact.dart';
class DecodedLogRxRoute {
final int payloadType;
final List<int> pathHashes;
const DecodedLogRxRoute({
required this.payloadType,
required this.pathHashes,
});
int? get originalSenderHash => pathHashes.isEmpty ? null : pathHashes.first;
}
class ResolvedNodeHash {
final int hash;
final String label;
final bool isOwnNode;
final bool isUniqueMatch;
final int matchCount;
const ResolvedNodeHash({
required this.hash,
required this.label,
required this.isOwnNode,
required this.isUniqueMatch,
required this.matchCount,
});
String get hexLabel => '0x${hash.toRadixString(16).padLeft(2, '0')}';
}
class LogRxRouteDecoder {
const LogRxRouteDecoder._();
static DecodedLogRxRoute? decode(Uint8List rawData) {
if (rawData.length < 5 || rawData[0] != 0x88) return null;
final rawPacketData = rawData.sublist(3);
if (rawPacketData.length < 2) return null;
final header = rawPacketData[0];
final routeType = header & 0x03;
final payloadType = (header >> 2) & 0x0F;
var index = 1;
if (routeType == 0x00 || routeType == 0x03) {
if (rawPacketData.length < index + 5) return null;
index += 4;
}
if (rawPacketData.length <= index) return null;
final pathLen = rawPacketData[index++];
if (rawPacketData.length < index + pathLen) return null;
return DecodedLogRxRoute(
payloadType: payloadType,
pathHashes: rawPacketData.sublist(index, index + pathLen),
);
}
static ResolvedNodeHash resolveHash(
int hash, {
required Iterable<Contact> contacts,
Uint8List? ownPublicKey,
String? ownName,
}) {
final ownHash = ownPublicKey != null && ownPublicKey.isNotEmpty
? ownPublicKey.first
: null;
if (ownHash == hash) {
final ownLabel = (ownName != null && ownName.trim().isNotEmpty)
? '$ownName (you)'
: 'You';
return ResolvedNodeHash(
hash: hash,
label: ownLabel,
isOwnNode: true,
isUniqueMatch: true,
matchCount: 1,
);
}
final matches = contacts.where((contact) {
return contact.publicKey.isNotEmpty && contact.publicKey.first == hash;
}).toList();
if (matches.isEmpty) {
return ResolvedNodeHash(
hash: hash,
label: 'Unknown',
isOwnNode: false,
isUniqueMatch: false,
matchCount: 0,
);
}
if (matches.length == 1) {
return ResolvedNodeHash(
hash: hash,
label: matches.first.displayName,
isOwnNode: false,
isUniqueMatch: true,
matchCount: 1,
);
}
final candidateNames = matches
.map((contact) => contact.displayName)
.where((name) => name.trim().isNotEmpty)
.take(2)
.join(', ');
final extraCount = matches.length - 2;
final label = candidateNames.isEmpty
? '${matches.length} contacts'
: extraCount > 0
? '$candidateNames +$extraCount'
: candidateNames;
return ResolvedNodeHash(
hash: hash,
label: label,
isOwnNode: false,
isUniqueMatch: false,
matchCount: matches.length,
);
}
}

View File

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

View File

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

View File

@@ -24,15 +24,30 @@ extension MessageLocalization on Message {
switch (deliveryStatus) {
case MessageDeliveryStatus.sending:
if (isContactMessage) {
if (retryAttempt > 0) {
return '${l10n.pending}${l10n.retryAttempt} $retryAttempt/3';
}
return l10n.pending;
}
return l10n.sending;
case MessageDeliveryStatus.sent:
return l10n.sent;
case MessageDeliveryStatus.delivered:
if (retryAttempt > 0 && roundTripTimeMs != null) {
return '${l10n.deliveredWithTime(roundTripTimeMs!)}${l10n.retryAttempt} $retryAttempt/3';
}
if (retryAttempt > 0) {
return '${l10n.delivered}${l10n.retryAttempt} $retryAttempt/3';
}
if (roundTripTimeMs != null) {
return l10n.deliveredWithTime(roundTripTimeMs!);
}
return l10n.delivered;
case MessageDeliveryStatus.failed:
if (retryAttempt > 0) {
return '${l10n.failed}${l10n.retryAttempt} $retryAttempt/3';
}
return l10n.failed;
case MessageDeliveryStatus.received:
return '';

View File

@@ -0,0 +1,87 @@
import 'dart:typed_data';
class RawRouteProbeRequest {
static const int _binaryMagic = 0x70; // 'p'
final int nonce;
final String requesterKey6;
const RawRouteProbeRequest({
required this.nonce,
required this.requesterKey6,
});
static RawRouteProbeRequest? tryParseBinary(Uint8List payload) {
if (payload.length != 11 || payload[0] != _binaryMagic) return null;
try {
final nonce =
(payload[1] << 24) |
(payload[2] << 16) |
(payload[3] << 8) |
payload[4];
final requesterKey6 = payload
.sublist(5, 11)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
return RawRouteProbeRequest(nonce: nonce, requesterKey6: requesterKey6);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
throw ArgumentError.value(
requesterKey6,
'requesterKey6',
'Expected 12 hex chars',
);
}
final out = Uint8List(11);
out[0] = _binaryMagic;
out[1] = (nonce >> 24) & 0xFF;
out[2] = (nonce >> 16) & 0xFF;
out[3] = (nonce >> 8) & 0xFF;
out[4] = nonce & 0xFF;
for (var i = 0; i < 6; i++) {
out[5 + i] = int.parse(
requesterKey6.substring(i * 2, i * 2 + 2),
radix: 16,
);
}
return out;
}
}
class RawRouteProbeAck {
static const int _binaryMagic = 0x71; // 'q'
final int nonce;
const RawRouteProbeAck({required this.nonce});
static RawRouteProbeAck? tryParseBinary(Uint8List payload) {
if (payload.length != 5 || payload[0] != _binaryMagic) return null;
try {
final nonce =
(payload[1] << 24) |
(payload[2] << 16) |
(payload[3] << 8) |
payload[4];
return RawRouteProbeAck(nonce: nonce);
} catch (_) {
return null;
}
}
Uint8List encodeBinary() {
final out = Uint8List(5);
out[0] = _binaryMagic;
out[1] = (nonce >> 24) & 0xFF;
out[2] = (nonce >> 16) & 0xFF;
out[3] = (nonce >> 8) & 0xFF;
out[4] = nonce & 0xFF;
return out;
}
}

View File

@@ -4,12 +4,66 @@ import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../models/contact.dart';
import '../models/message.dart';
import '../models/message_contact_location.dart';
import '../l10n/app_localizations.dart';
/// Generates sample data for testing/demo purposes
class SampleDataGenerator {
static final Random _random = Random();
static MessageContactLocation _sampleSnapshot({
required LatLng location,
required DateTime receivedAt,
required String source,
}) {
return MessageContactLocation(
location: location,
source: source,
capturedAt: receivedAt,
sourceTimestamp: receivedAt.subtract(const Duration(minutes: 2)),
);
}
static LatLng _randomNearbyLocation(LatLng center, double spread) {
final latOffset = (_random.nextDouble() - 0.5) * spread;
final lonOffset = (_random.nextDouble() - 0.5) * spread;
return LatLng(center.latitude + latOffset, center.longitude + lonOffset);
}
static SampleMessageBatch generateAllMessages({
required LatLng centerLocation,
required AppLocalizations l10n,
int foundPersonCount = 2,
int fireCount = 1,
int stagingCount = 1,
int objectCount = 1,
int generalChannelMessages = 8,
int emergencyChannelMessages = 5,
}) {
final sarBatch = generateSarMarkerMessages(
centerLocation: centerLocation,
l10n: l10n,
foundPersonCount: foundPersonCount,
fireCount: fireCount,
stagingCount: stagingCount,
objectCount: objectCount,
);
final channelBatch = generateChannelMessages(
centerLocation: centerLocation,
l10n: l10n,
generalChannelMessages: generalChannelMessages,
emergencyChannelMessages: emergencyChannelMessages,
);
return SampleMessageBatch(
messages: [...sarBatch.messages, ...channelBatch.messages],
contactLocations: {
...sarBatch.contactLocations,
...channelBatch.contactLocations,
},
);
}
/// Generate sample contacts around a center location
static List<Contact> generateContacts({
required LatLng centerLocation,
@@ -113,7 +167,7 @@ class SampleDataGenerator {
}
/// Generate sample SAR markers around a center location
static List<Message> generateSarMarkerMessages({
static SampleMessageBatch generateSarMarkerMessages({
required LatLng centerLocation,
required AppLocalizations l10n,
int foundPersonCount = 2,
@@ -122,6 +176,7 @@ class SampleDataGenerator {
int objectCount = 1,
}) {
final messages = <Message>[];
final contactLocations = <String, MessageContactLocation>{};
final now = DateTime.now();
int messageId = 1;
@@ -137,7 +192,7 @@ class SampleDataGenerator {
);
final timestamp = now.subtract(Duration(minutes: 10 + i * 5));
messages.add(Message(
final message = Message(
id: 'sample_fp_$messageId',
messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6),
@@ -150,7 +205,13 @@ class SampleDataGenerator {
sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🧑',
senderName: l10n.sampleTeamMember,
));
);
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.006),
receivedAt: timestamp,
source: 'telemetry',
);
messageId++;
}
@@ -166,7 +227,7 @@ class SampleDataGenerator {
);
final timestamp = now.subtract(Duration(minutes: 20 + i * 5));
messages.add(Message(
final message = Message(
id: 'sample_fire_$messageId',
messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6),
@@ -179,7 +240,13 @@ class SampleDataGenerator {
sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🔥',
senderName: l10n.sampleScout,
));
);
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.008),
receivedAt: timestamp,
source: 'advert',
);
messageId++;
}
@@ -195,7 +262,7 @@ class SampleDataGenerator {
);
final timestamp = now.subtract(Duration(minutes: 30 + i * 5));
messages.add(Message(
final message = Message(
id: 'sample_staging_$messageId',
messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6),
@@ -208,7 +275,13 @@ class SampleDataGenerator {
sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '🏕️',
senderName: l10n.sampleBase,
));
);
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.01),
receivedAt: timestamp,
source: 'advert',
);
messageId++;
}
@@ -231,24 +304,34 @@ class SampleDataGenerator {
l10n.sampleObjectTrailMarker,
];
messages.add(Message(
final message = Message(
id: 'sample_object_$messageId',
messageType: MessageType.contact,
senderPublicKeyPrefix: senderKey.sublist(0, 6),
pathLen: 1,
textType: MessageTextType.plain,
senderTimestamp: timestamp.millisecondsSinceEpoch ~/ 1000,
text: 'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
text:
'S:📦:${lat.toStringAsFixed(5)},${lon.toStringAsFixed(5)}${notes[i % notes.length]}',
receivedAt: timestamp,
isSarMarker: true,
sarGpsCoordinates: LatLng(lat, lon),
sarCustomEmoji: '📦',
senderName: l10n.sampleSearcher,
));
);
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(message.sarGpsCoordinates!, 0.007),
receivedAt: timestamp,
source: 'telemetry',
);
messageId++;
}
return messages;
return SampleMessageBatch(
messages: messages,
contactLocations: contactLocations,
);
}
/// Generate sample map drawings
@@ -272,7 +355,9 @@ class SampleDataGenerator {
'id': 'sample_line_${now.millisecondsSinceEpoch}',
'color': Colors.blue.toARGB32(),
'createdAt': now.subtract(const Duration(minutes: 15)).toIso8601String(),
'points': linePoints.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
'points': linePoints
.map((p) => {'lat': p.latitude, 'lon': p.longitude})
.toList(),
'sender': l10n.sampleTeamMember,
});
@@ -297,7 +382,7 @@ class SampleDataGenerator {
}
/// Generate sample channel messages for public channels
static List<Message> generateChannelMessages({
static SampleMessageBatch generateChannelMessages({
LatLng? centerLocation,
required AppLocalizations l10n,
int generalChannelMessages = 8,
@@ -306,6 +391,7 @@ class SampleDataGenerator {
// Use provided location or default to Ljubljana, Slovenia
final center = centerLocation ?? const LatLng(46.0569, 14.5058);
final messages = <Message>[];
final contactLocations = <String, MessageContactLocation>{};
final now = DateTime.now();
int messageId = 1000; // Start with high ID to avoid conflicts
@@ -352,7 +438,11 @@ class SampleDataGenerator {
];
// Generate General channel messages
for (int i = 0; i < generalChannelMessages && i < generalMessages.length; i++) {
for (
int i = 0;
i < generalChannelMessages && i < generalMessages.length;
i++
) {
final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)),
);
@@ -361,7 +451,7 @@ class SampleDataGenerator {
final minutesAgo = 120 - (i * 15) - _random.nextInt(10);
final timestamp = now.subtract(Duration(minutes: minutesAgo));
messages.add(Message(
final message = Message(
id: 'sample_general_$messageId',
messageType: MessageType.channel,
channelIdx: 0, // General channel
@@ -372,12 +462,22 @@ class SampleDataGenerator {
text: generalMessages[i],
receivedAt: timestamp,
senderName: teamNames[_random.nextInt(teamNames.length)],
));
);
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(center, 0.018),
receivedAt: timestamp,
source: i.isEven ? 'telemetry' : 'advert',
);
messageId++;
}
// Generate Emergency channel messages
for (int i = 0; i < emergencyChannelMessages && i < emergencyMessages.length; i++) {
for (
int i = 0;
i < emergencyChannelMessages && i < emergencyMessages.length;
i++
) {
final senderKey = Uint8List.fromList(
List.generate(32, (_) => _random.nextInt(256)),
);
@@ -386,7 +486,7 @@ class SampleDataGenerator {
final minutesAgo = 60 - (i * 10) - _random.nextInt(5);
final timestamp = now.subtract(Duration(minutes: minutesAgo));
messages.add(Message(
final message = Message(
id: 'sample_emergency_$messageId',
messageType: MessageType.channel,
channelIdx: 1, // Emergency channel
@@ -397,10 +497,29 @@ class SampleDataGenerator {
text: emergencyMessages[i],
receivedAt: timestamp,
senderName: teamNames[_random.nextInt(teamNames.length)],
));
);
messages.add(message);
contactLocations[message.id] = _sampleSnapshot(
location: _randomNearbyLocation(center, 0.012),
receivedAt: timestamp,
source: i.isEven ? 'advert' : 'telemetry',
);
messageId++;
}
return messages;
return SampleMessageBatch(
messages: messages,
contactLocations: contactLocations,
);
}
}
class SampleMessageBatch {
final List<Message> messages;
final Map<String, MessageContactLocation> contactLocations;
const SampleMessageBatch({
required this.messages,
required this.contactLocations,
});
}

View File

@@ -3,7 +3,12 @@ import 'dart:typed_data';
import '../models/contact.dart';
import '../providers/contacts_provider.dart';
enum TransmissionTargetFailure { unknownContact, unknownRoute, tooFar }
enum TransmissionTargetFailure {
unknownContact,
unknownRoute,
tooFar,
unreachable,
}
class TransmissionTargetResolution {
final Contact? target;
@@ -16,7 +21,7 @@ class TransmissionTargetResolution {
required this.maxHops,
});
int get hops => target?.outPathLen ?? -1;
int get hops => target?.routeHopCount ?? -1;
bool get isValid => target != null && failure == null;
}
@@ -32,11 +37,17 @@ class TransmissionTargetResolver {
String? senderName,
}) {
if (isSentByMe) {
final recipient = _findByRecipientKey(contactsProvider, recipientPublicKey);
final recipient = _findByRecipientKey(
contactsProvider,
recipientPublicKey,
);
if (recipient != null) return recipient;
}
final byEnvelope = _findByEnvelopeKey6(contactsProvider, senderKey6FromEnvelope);
final byEnvelope = _findByEnvelopeKey6(
contactsProvider,
senderKey6FromEnvelope,
);
if (byEnvelope != null) return byEnvelope;
final byPrefix = _findByPrefix(contactsProvider, senderPublicKeyPrefix);
@@ -64,7 +75,9 @@ class TransmissionTargetResolver {
senderName: senderName,
);
if (target == null || target.outPathLen < 0 || target.outPathLen > maxFetchHops) {
if (target == null ||
!target.routeHasPath ||
target.routeHopCount > maxFetchHops) {
await refreshContacts();
target = resolveLocalTarget(
contactsProvider: contactsProvider,
@@ -83,14 +96,14 @@ class TransmissionTargetResolver {
maxHops: maxFetchHops,
);
}
if (target.outPathLen < 0) {
if (!target.routeHasPath) {
return TransmissionTargetResolution(
target: target,
failure: TransmissionTargetFailure.unknownRoute,
maxHops: maxFetchHops,
);
}
if (target.outPathLen > maxFetchHops) {
if (target.routeHopCount > maxFetchHops) {
return TransmissionTargetResolution(
target: target,
failure: TransmissionTargetFailure.tooFar,

View File

@@ -2,7 +2,7 @@ import 'dart:convert';
import 'dart:typed_data';
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
const int _voicePacketHeaderBytes = 8; // voice packet binary header in payload
const int _voicePacketHeaderBytes = 6; // voice packet binary header in payload
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
@@ -38,7 +38,7 @@ enum VoicePacketMode {
/// V:{sessionId8hex}:{modeId}:{index}/{total}:{base64Codec2}
///
/// Binary format (direct contacts, received via pushRawData):
/// [0x56 'V'][sessionId:4B][modeId:1B][index:1B][total:1B][codec2Data...]
/// [0x56 'V'][sessionId:4B][index:1B][codec2Data...]
class VoicePacket {
final String sessionId; // 8 hex chars (4 bytes)
final VoicePacketMode mode;
@@ -104,8 +104,7 @@ class VoicePacket {
// ── Binary format ────────────────────────────────────────────────────────
static const int _binaryMagic = 0x56; // 'V'
static const int _binaryHeaderLen =
8; // magic(1)+session(4)+mode(1)+idx(1)+total(1)
static const int _binaryHeaderLen = 6; // magic(1)+session(4)+idx(1)
static bool isVoiceBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic;
@@ -119,16 +118,13 @@ class VoicePacket {
final sessionId = sessionBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
final modeId = payload[5];
final index = payload[6];
final total = payload[7];
if (total < 1) return null;
final index = payload[5];
final codec2Data = payload.sublist(_binaryHeaderLen);
return VoicePacket(
sessionId: sessionId,
mode: VoicePacketMode.fromId(modeId),
mode: VoicePacketMode.mode1300,
index: index,
total: total,
total: 0,
codec2Data: codec2Data,
);
} catch (_) {
@@ -148,9 +144,7 @@ class VoicePacket {
final out = Uint8List(_binaryHeaderLen + codec2Data.length);
out[0] = _binaryMagic;
out.setRange(1, 5, sessionBytes);
out[5] = mode.id;
out[6] = index;
out[7] = total;
out[5] = index;
out.setRange(_binaryHeaderLen, out.length, codec2Data);
return out;
}
@@ -174,25 +168,25 @@ class VoicePacket {
}
@override
String toString() =>
'VoicePacket($sessionId ${mode.label} [$index/${total - 1}] ${codec2Data.length}B)';
String toString() {
final suffix = total > 0 ? ' ${mode.label} [$index/${total - 1}]' : ' [$index]';
return 'VoicePacket($sessionId$suffix ${codec2Data.length}B)';
}
}
/// Lightweight public/direct message envelope advertising voice availability.
///
/// Text format:
/// VE2:{sid}:{mode}:{total}:{durS}:{senderKey6}:{ts}
/// VE3:{sid}:{mode}:{total}:{durS}
/// Example:
/// VE2:00112233:1:4:4:aabbccddeeff:kf12oi
/// VE3:00112233:1:4:4
class VoiceEnvelope {
static const String _prefix = 'VE2:';
static const String _prefix = 'VE3:';
final String sessionId;
final VoicePacketMode mode;
final int total;
final int durationMs;
final String senderKey6;
final int timestampSec;
final int version;
const VoiceEnvelope({
@@ -200,9 +194,7 @@ class VoiceEnvelope {
required this.mode,
required this.total,
required this.durationMs,
required this.senderKey6,
required this.timestampSec,
this.version = 2,
this.version = 3,
});
static bool isVoiceEnvelopeText(String text) => text.startsWith(_prefix);
@@ -215,14 +207,12 @@ class VoiceEnvelope {
static VoiceEnvelope? _tryParse(String body) {
final parts = body.split(':');
if (parts.length != 6) return null;
if (parts.length != 4) return null;
try {
final sid = _decodeSessionId(parts[0]);
final mode = _parseInt(parts[1], base36: true);
final total = _parseInt(parts[2], base36: true);
final durS = _parseInt(parts[3], base36: true);
final senderKey6 = parts[4];
final ts = _parseInt(parts[5], base36: true);
if (sid == null) {
return null;
@@ -232,19 +222,13 @@ class VoiceEnvelope {
}
if (total == null || total < 1 || total > 255) return null;
if (durS == null || durS < 0 || durS > 10 * 60) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) {
return null;
}
if (ts == null || ts <= 0) return null;
return VoiceEnvelope(
sessionId: sid,
mode: VoicePacketMode.fromId(mode),
total: total,
durationMs: durS * 1000,
senderKey6: senderKey6.toLowerCase(),
timestampSec: ts,
version: 2,
version: 3,
);
} catch (_) {
return null;
@@ -253,7 +237,7 @@ class VoiceEnvelope {
String encodeText() {
final durationSec = (durationMs / 1000).ceil().clamp(0, 10 * 60);
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}:${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
return '$_prefix${_encodeSessionId(sessionId)}:${_toBase36(mode.id)}:${_toBase36(total)}:${_toBase36(durationSec)}';
}
}
@@ -414,18 +398,17 @@ int _resolveBandwidthHz(int? rawBw) {
/// Direct control-plane request to fetch voice packets for a session.
///
/// Text format:
/// VR2:{sid}:{want}:{requesterKey6}:{ts}
/// VR3:{sid}:{want}:{requesterKey6}
/// Example:
/// VR2:00112233:a:aabbccddeeff:kf12oi
/// VR3:00112233:a:aabbccddeeff
class VoiceFetchRequest {
static const String _prefix = 'VR2:';
static const String _prefix = 'VR3:';
static const int _binaryMagic = 0x72; // 'r'
final String sessionId;
final String want;
final List<int> missingIndices;
final String requesterKey6;
final int timestampSec;
final int version;
const VoiceFetchRequest({
@@ -433,8 +416,7 @@ class VoiceFetchRequest {
this.want = 'all',
this.missingIndices = const [],
required this.requesterKey6,
required this.timestampSec,
this.version = 2,
this.version = 3,
});
static bool isVoiceFetchRequestText(String text) =>
@@ -450,7 +432,7 @@ class VoiceFetchRequest {
static VoiceFetchRequest? tryParseBinary(Uint8List payload) {
if (!isVoiceFetchRequestBinary(payload)) return null;
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count
if (payload.length < 13) return null; // magic+sid+flags+key6+count
try {
final sidBytes = payload.sublist(1, 5);
final sid = sidBytes
@@ -463,25 +445,19 @@ class VoiceFetchRequest {
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toLowerCase();
final ts =
(payload[12] << 24) |
(payload[13] << 16) |
(payload[14] << 8) |
payload[15];
final missingCount = payload[16];
if (payload.length != 17 + missingCount) return null;
final missingCount = payload[12];
if (payload.length != 13 + missingCount) return null;
final wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[];
for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]);
missing.add(payload[13 + i]);
}
return VoiceFetchRequest(
sessionId: sid,
want: wantMissing ? 'missing' : 'all',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: ts,
version: 2,
version: 3,
);
} catch (_) {
return null;
@@ -490,12 +466,11 @@ class VoiceFetchRequest {
static VoiceFetchRequest? _tryParse(String body) {
final parts = body.split(':');
if (parts.length != 4) return null;
if (parts.length != 3) return null;
try {
final sid = _decodeSessionId(parts[0]);
final wantToken = parts[1];
final requesterKey6 = parts[2];
final ts = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a'
? 'all'
: ((wantToken.startsWith('m'))
@@ -517,15 +492,13 @@ class VoiceFetchRequest {
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) {
return null;
}
if (ts == null || ts <= 0) return null;
return VoiceFetchRequest(
sessionId: sid,
want: normalizedWant,
missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts,
version: 2,
version: 3,
);
} catch (_) {
return null;
@@ -536,7 +509,7 @@ class VoiceFetchRequest {
final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want);
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}';
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
}
Uint8List encodeBinary() {
@@ -555,7 +528,7 @@ class VoiceFetchRequest {
? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[];
final out = Uint8List(17 + missing.length);
final out = Uint8List(13 + missing.length);
out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
@@ -567,13 +540,9 @@ class VoiceFetchRequest {
radix: 16,
);
}
out[12] = (timestampSec >> 24) & 0xFF;
out[13] = (timestampSec >> 16) & 0xFF;
out[14] = (timestampSec >> 8) & 0xFF;
out[15] = timestampSec & 0xFF;
out[16] = missing.length;
out[12] = missing.length;
for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i];
out[13 + i] = missing[i];
}
return out;
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../services/trail_color_service.dart';
import '../../utils/avatar_label_helper.dart';
class ContactAvatar extends StatelessWidget {
final Contact contact;
final double radius;
final String? displayName;
const ContactAvatar({
super.key,
required this.contact,
this.radius = 20,
this.displayName,
});
@override
Widget build(BuildContext context) {
final backgroundColor = _getBackgroundColor(context);
final foregroundColor = _getForegroundColor(backgroundColor);
final emoji = contact.roleEmoji;
if (emoji != null && emoji.isNotEmpty) {
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
);
}
if (_shouldUseLabelFallback) {
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Text(
AvatarLabelHelper.buildLabel(displayName ?? contact.displayName),
style: TextStyle(
color: foregroundColor,
fontSize: radius * 0.68,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
),
),
);
}
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Icon(
_getTypeIcon(contact.type),
color: foregroundColor,
size: radius,
),
);
}
Widget _buildAvatarFrame({
required Color backgroundColor,
required Widget child,
}) {
if (_usesSquareShape) {
return Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(radius * 0.6),
),
alignment: Alignment.center,
child: child,
);
}
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
child: child,
);
}
bool get _shouldUseLabelFallback =>
contact.type == ContactType.chat ||
contact.type == ContactType.channel ||
contact.type == ContactType.room;
bool get _usesSquareShape =>
contact.type == ContactType.channel || contact.type == ContactType.room;
Color _getBackgroundColor(BuildContext context) {
if (_shouldUseLabelFallback || (contact.roleEmoji?.isNotEmpty ?? false)) {
return TrailColorService.getTrailColor(contact);
}
switch (contact.type) {
case ContactType.none:
return Theme.of(context).colorScheme.surfaceContainerHighest;
case ContactType.chat:
return Colors.blue;
case ContactType.repeater:
return Colors.orange;
case ContactType.room:
return Colors.purple;
case ContactType.channel:
return Colors.teal;
}
}
Color _getForegroundColor(Color backgroundColor) {
return ThemeData.estimateBrightnessForColor(backgroundColor) ==
Brightness.dark
? Colors.white
: Colors.black87;
}
IconData _getTypeIcon(ContactType type) {
switch (type) {
case ContactType.none:
return Icons.help_outline;
case ContactType.chat:
return Icons.person;
case ContactType.repeater:
return Icons.router;
case ContactType.room:
return Icons.meeting_room;
case ContactType.channel:
return Icons.public;
}
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/location_formats.dart';
/// Reusable location display widget with tap-to-show modal
/// Shows coordinates in a compact format with ability to view all formats
@@ -35,9 +36,9 @@ class LocationDisplay extends StatelessWidget {
Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
),
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Icon(
@@ -54,9 +55,9 @@ class LocationDisplay extends StatelessWidget {
// Non-compact version (just text)
return Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
);
}
@@ -79,10 +80,7 @@ class LocationDisplay extends StatelessWidget {
children: [
const Text(
'Location Formats',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
@@ -121,7 +119,7 @@ class LocationDisplay extends StatelessWidget {
_buildFormatRow(
context,
'Plus Code',
_convertToPlusCode(location.latitude, location.longitude),
formatPlusCode(location.latitude, location.longitude),
),
const SizedBox(height: 8),
],
@@ -140,9 +138,9 @@ class LocationDisplay extends StatelessWidget {
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
InkWell(
@@ -150,7 +148,9 @@ class LocationDisplay extends StatelessWidget {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.copiedToClipboard(label)),
content: Text(
AppLocalizations.of(context)!.copiedToClipboard(label),
),
duration: const Duration(seconds: 2),
),
);
@@ -168,9 +168,9 @@ class LocationDisplay extends StatelessWidget {
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w500,
),
fontFamily: 'monospace',
fontWeight: FontWeight.w500,
),
),
),
Icon(
@@ -242,31 +242,4 @@ class LocationDisplay extends StatelessWidget {
// Full MGRS would require UTM conversion library
return '$zone$letter (approximate)';
}
/// Convert to Google Plus Code format
/// Simplified implementation - returns approximate code
String _convertToPlusCode(double lat, double lon) {
// This is a simplified version - full Plus Code requires the open_location_code package
const base = '23456789CFGHJMPQRVWX';
// Normalize coordinates
lat = (lat + 90) / 180; // 0 to 1
lon = (lon + 180) / 360; // 0 to 1
String code = '';
for (int i = 0; i < 8; i++) {
if (i == 4) code += '+';
int latDigit = (lat * 20).floor() % 20;
int lonDigit = (lon * 20).floor() % 20;
code += base[latDigit];
code += base[lonDigit];
lat = (lat * 20) % 1;
lon = (lon * 20) % 1;
}
return code;
}
}

View File

@@ -21,10 +21,19 @@ class _ConnectionDialogState extends State<ConnectionDialog>
final List<DiscoveredServer> _discoveredServers = [];
int _scannedCount = 0;
int _totalToScan = 0;
String? _connectingToServerKey; // Track which server is being connected to (ip:port)
int _lastTabIndex = 0;
String?
_connectingToServerKey; // Track which server is being connected to (ip:port)
// Named listener method for proper cleanup
void _onTabChanged() {
if (_tabController.index == _lastTabIndex) return;
_lastTabIndex = _tabController.index;
if (_tabController.index == 0) {
_refreshBleDevices();
}
if (_tabController.index == 1) {
// Switched to network tab
if (_networkScanner.hasCachedResults && _discoveredServers.isEmpty) {
@@ -47,13 +56,16 @@ class _ConnectionDialogState extends State<ConnectionDialog>
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
_connectionProvider = Provider.of<ConnectionProvider>(context, listen: false);
_connectionProvider = Provider.of<ConnectionProvider>(
context,
listen: false,
);
// Defer scan startup until after the first frame so Provider listeners
// are not notified while this dialog is still being built.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_connectionProvider.startScan();
_refreshBleDevices();
});
// Set up network scanner callbacks
@@ -101,6 +113,12 @@ class _ConnectionDialogState extends State<ConnectionDialog>
_networkScanner.scan();
}
Future<void> _refreshBleDevices() async {
await _connectionProvider.stopScan();
if (!mounted) return;
await _connectionProvider.startScan();
}
Color _getSignalColor(int rssi) {
if (rssi >= -60) return Colors.green;
if (rssi >= -75) return Colors.orange;
@@ -218,10 +236,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
Icons.refresh,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
onPressed: () {
connectionProvider.stopScan();
connectionProvider.startScan();
},
onPressed: _refreshBleDevices,
),
],
),
@@ -255,10 +270,7 @@ class _ConnectionDialogState extends State<ConnectionDialog>
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () {
connectionProvider.stopScan();
connectionProvider.startScan();
},
onPressed: _refreshBleDevices,
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context)!.scanAgain),
),

View File

@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../services/route_hash_preferences.dart';
class ContactRouteDialogResult {
final ParsedContactRoute? route;
final bool shouldClear;
const ContactRouteDialogResult._({this.route, required this.shouldClear});
const ContactRouteDialogResult.set(ParsedContactRoute route)
: this._(route: route, shouldClear: false);
const ContactRouteDialogResult.clear() : this._(shouldClear: true);
}
class ContactRouteDialog extends StatefulWidget {
final Contact contact;
final List<Contact> availableContacts;
const ContactRouteDialog({
super.key,
required this.contact,
required this.availableContacts,
});
static Future<ContactRouteDialogResult?> show(
BuildContext context, {
required Contact contact,
required List<Contact> availableContacts,
}) {
return showModalBottomSheet<ContactRouteDialogResult>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: ContactRouteDialog(
contact: contact,
availableContacts: availableContacts,
),
),
),
);
}
@override
State<ContactRouteDialog> createState() => _ContactRouteDialogState();
}
class _ContactRouteDialogState extends State<ContactRouteDialog> {
late final TextEditingController _controller;
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
ParsedContactRoute? _parsedRoute;
String? _errorText;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: widget.contact.routeCanonicalText,
);
_controller.addListener(_reparse);
_loadHashSizePreference();
_reparse();
}
@override
void dispose() {
_controller
..removeListener(_reparse)
..dispose();
super.dispose();
}
void _reparse() {
final input = _controller.text.trim();
if (input.isEmpty) {
setState(() {
_parsedRoute = null;
_errorText = null;
});
return;
}
try {
final parsed = ContactRouteCodec.parse(
input,
expectedHashSize: _selectedHashSize,
);
setState(() {
_parsedRoute = parsed;
_errorText = null;
});
} on ContactRouteFormatException catch (error) {
setState(() {
_parsedRoute = null;
_errorText = error.message;
});
}
}
Future<void> _loadHashSizePreference() async {
final hashSize = await RouteHashPreferences.getHashSize();
if (!mounted) return;
setState(() {
_selectedHashSize = hashSize;
});
_reparse();
}
String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2;
if (hex.length < length) {
return hex;
}
return hex.substring(0, length);
}
void _appendHop(Contact contact) {
final token = _tokenFor(contact, _selectedHashSize);
final current = _controller.text.trim();
_controller.text = current.isEmpty ? token : '$current,$token';
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
}
@override
Widget build(BuildContext context) {
final routeCandidates =
widget.availableContacts
.where((contact) => contact.isRepeater || contact.isRoom)
.toList()
..sort((a, b) => a.displayName.compareTo(b.displayName));
return FractionallySizedBox(
heightFactor: 0.85,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Set Route for ${widget.contact.displayName}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
TextField(
controller: _controller,
textCapitalization: TextCapitalization.characters,
decoration: InputDecoration(
labelText: 'Route',
hintText: _selectedHashSize == 1
? 'AA,BB,CC'
: _selectedHashSize == 2
? 'AABB,CCDD'
: 'AABBCC,DDEEFF',
helperText:
'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
errorText: _errorText,
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Text(
_parsedRoute == null
? 'Preview: enter a route to validate it.'
: 'Preview: ${_parsedRoute!.summary}${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
style: Theme.of(context).textTheme.bodySmall,
),
if (_parsedRoute != null) ...[
const SizedBox(height: 4),
SelectableText(
_parsedRoute!.canonicalText,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
),
],
const SizedBox(height: 16),
Text(
'Pick hops from contacts',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
if (routeCandidates.isEmpty)
const Text(
'No repeater or room contacts are available for route building.',
)
else
Expanded(
child: ListView.builder(
itemCount: routeCandidates.length,
itemBuilder: (context, index) {
final candidate = routeCandidates[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(candidate.displayName),
subtitle: Text(
'1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}',
style: const TextStyle(fontFamily: 'monospace'),
),
trailing: TextButton(
onPressed: () => _appendHop(candidate),
child: Text(
'Use ${_tokenFor(candidate, _selectedHashSize)}',
),
),
);
},
),
),
const SizedBox(height: 16),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
if (widget.contact.routeHasPath)
TextButton(
onPressed: () => Navigator.of(
context,
).pop(const ContactRouteDialogResult.clear()),
child: const Text('Clear Route'),
),
const Spacer(),
FilledButton(
onPressed: _parsedRoute == null
? null
: () => Navigator.of(
context,
).pop(ContactRouteDialogResult.set(_parsedRoute!)),
child: const Text('Set Route'),
),
],
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,452 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/messages_provider.dart';
import '../../providers/app_provider.dart';
import '../../utils/toast_logger.dart';
import '../../l10n/app_localizations.dart';
class DirectMessageSheet extends StatefulWidget {
final Contact contact;
const DirectMessageSheet({super.key, required this.contact});
@override
State<DirectMessageSheet> createState() => _DirectMessageSheetState();
}
class _DirectMessageSheetState extends State<DirectMessageSheet> {
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
int _characterCount = 0;
static const int _maxCharacters = 160;
@override
void initState() {
super.initState();
_textController.addListener(_updateCharacterCount);
}
@override
void dispose() {
_textController.dispose();
_focusNode.dispose();
super.dispose();
}
void _updateCharacterCount() {
if (!mounted) return;
setState(() {
_characterCount = _textController.text.length;
});
}
/// Insert current GPS location at cursor position
Future<void> _insertCurrentLocation() async {
try {
// Check location permission
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
if (!mounted) return;
ToastLogger.error(context, 'Location permission denied');
return;
}
}
if (permission == LocationPermission.deniedForever) {
if (!mounted) return;
ToastLogger.error(context, 'Location permission permanently denied');
return;
}
// Get current position
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
),
);
// Format location text
final locationText =
'📍 Lat: ${position.latitude.toStringAsFixed(5)}, Lon: ${position.longitude.toStringAsFixed(5)}';
// Check if adding location would exceed limit
final currentText = _textController.text;
if (currentText.length + locationText.length > _maxCharacters) {
if (!mounted) return;
ToastLogger.error(
context,
'Adding location would exceed 160 character limit',
);
return;
}
// Insert at cursor position or append
final selection = _textController.selection;
final newText = currentText.replaceRange(
selection.start >= 0 ? selection.start : currentText.length,
selection.end >= 0 ? selection.end : currentText.length,
locationText,
);
_textController.text = newText;
// Move cursor to end of inserted text
final newCursorPosition =
(selection.start >= 0 ? selection.start : currentText.length) +
locationText.length;
_textController.selection = TextSelection.fromPosition(
TextPosition(offset: newCursorPosition),
);
if (!mounted) return;
} catch (e) {
if (!mounted) return;
ToastLogger.error(context, 'Failed to get location: $e');
}
}
Future<void> _sendDirectMessage() async {
final text = _textController.text.trim();
if (text.isEmpty) return;
final connectionProvider = context.read<ConnectionProvider>();
final messagesProvider = context.read<MessagesProvider>();
if (!connectionProvider.deviceInfo.isConnected) {
if (!mounted) return;
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
try {
// Create message ID
final messageId = '${DateTime.now().millisecondsSinceEpoch}_dm_sent';
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// Get current device's public key (first 6 bytes)
final devicePublicKey = connectionProvider.deviceInfo.publicKey;
final senderPublicKeyPrefix = devicePublicKey?.sublist(0, 6);
// Create sent message object with recipient public key for retry support
final sentMessage = Message(
id: messageId,
messageType: MessageType.contact,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: timestamp,
text: text,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey:
widget.contact.publicKey, // Store recipient for retry
);
// Add to messages list with "sending" status
// Pass contact for retry logic
messagesProvider.addSentMessage(sentMessage, contact: widget.contact);
// Send direct message to contact (include contact for path logging)
final sentSuccessfully = await connectionProvider.sendTextMessage(
contactPublicKey: widget.contact.publicKey,
text: text,
messageId: messageId, // Pass message ID for tracking
contact: widget.contact,
);
if (!sentSuccessfully) {
// Mark message as failed if sending failed
messagesProvider.markMessageFailed(messageId);
}
_textController.clear();
_focusNode.unfocus();
if (!mounted) return;
Navigator.pop(context); // Close the dialog
} catch (e) {
if (!mounted) return;
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToSend(e.toString()),
);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final appProvider = context.watch<AppProvider>();
final isSimpleMode = appProvider.isSimpleMode;
final contactLocation = widget.contact.displayLocation;
return Container(
height: MediaQuery.of(context).size.height * 0.9,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
onPressed: () => Navigator.pop(context),
),
Expanded(
child: Column(
children: [
Text(
AppLocalizations.of(context)!.directMessage,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
widget.contact.displayName,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
],
),
),
const SizedBox(width: 48), // Spacer to keep title centered
],
),
),
// Mini map in simple mode (scrollable content)
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 16),
if (isSimpleMode && contactLocation != null) ...[
GestureDetector(
onTap: () {
// Hide keyboard when tapping on map
_focusNode.unfocus();
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
height: 200,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.outline),
),
clipBehavior: Clip.antiAlias,
child: FlutterMap(
options: MapOptions(
initialCenter: LatLng(
contactLocation.latitude,
contactLocation.longitude,
),
initialZoom: 13.0,
interactionOptions: const InteractionOptions(
flags:
InteractiveFlag.pinchZoom |
InteractiveFlag.drag,
),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
),
MarkerLayer(
markers: [
Marker(
point: LatLng(
contactLocation.latitude,
contactLocation.longitude,
),
width: 40,
height: 40,
child: Icon(
Icons.location_on,
color: colorScheme.primary,
size: 40,
),
),
],
),
],
),
),
),
const SizedBox(height: 8),
// Location coordinates
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.gps_fixed,
size: 14,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
'${contactLocation.latitude.toStringAsFixed(5)}, ${contactLocation.longitude.toStringAsFixed(5)}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 12,
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(height: 16),
],
],
),
),
),
// Message input
Container(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
),
child: Column(
children: [
TextField(
controller: _textController,
focusNode: _focusNode,
maxLength: _maxCharacters,
maxLines: 3,
autofocus: true,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.all(16),
counterText: '', // Hide default counter
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendDirectMessage(),
),
// Always-visible character counter
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 4,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'$_characterCount / $_maxCharacters',
style: TextStyle(
fontSize: 12,
color: _characterCount > 155
? Colors.red
: (_characterCount > 140
? Colors.orange
: colorScheme.onSurfaceVariant),
fontWeight: _characterCount > 140
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
const SizedBox(height: 8),
// Location and Send buttons
Row(
children: [
OutlinedButton.icon(
onPressed: _insertCurrentLocation,
icon: const Icon(Icons.my_location, size: 18),
label: Text(AppLocalizations.of(context)!.myLocation),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
side: BorderSide(color: colorScheme.outline),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _textController.text.trim().isEmpty
? null
: _sendDirectMessage,
icon: const Icon(Icons.send),
label: Text(
AppLocalizations.of(context)!.sendDirectMessage,
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
disabledBackgroundColor:
colorScheme.surfaceContainerHighest,
disabledForegroundColor: colorScheme.onSurfaceVariant,
),
),
),
],
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,394 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart';
class SpectrumScanPanel extends StatelessWidget {
final ThemeData theme;
final bool scanSupported;
final bool isRunning;
final double rangeMinMhz;
final double rangeMaxMhz;
final RangeValues rangeValues;
final double bandwidthKhz;
final int? selectedFrequencyKhz;
final List<SpectrumScanCandidate> graphCandidates;
final List<SpectrumScanCandidate> selectableCandidates;
final ValueChanged<RangeValues> onRangeChanged;
final ValueChanged<int?> onCandidateChanged;
final VoidCallback onRunScan;
final VoidCallback onApplySelected;
const SpectrumScanPanel({
super.key,
required this.theme,
required this.scanSupported,
required this.isRunning,
required this.rangeMinMhz,
required this.rangeMaxMhz,
required this.rangeValues,
required this.bandwidthKhz,
required this.selectedFrequencyKhz,
required this.graphCandidates,
required this.selectableCandidates,
required this.onRangeChanged,
required this.onCandidateChanged,
required this.onRunScan,
required this.onApplySelected,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.45,
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.tune, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
'Power Scan',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
FilledButton.icon(
onPressed: scanSupported && !isRunning ? onRunScan : null,
icon: isRunning
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.radar),
label: Text(
scanSupported
? (isRunning ? 'Scanning' : 'Scan')
: 'Unavailable',
),
),
],
),
const SizedBox(height: 8),
Text(
scanSupported
? 'Full range with bandwidth footprint. Firmware enforces hardware band limits and pauses the mesh while scanning.'
: 'Full range with bandwidth footprint. This companion does not support spectrum scan mode, so scanning is disabled.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
_FrequencyRangePreview(
minMhz: rangeMinMhz,
maxMhz: rangeMaxMhz,
selectedRange: rangeValues,
selectedBandwidthKhz: bandwidthKhz,
selectedFrequencyKhz: selectedFrequencyKhz,
candidates: graphCandidates,
),
const SizedBox(height: 10),
Wrap(
spacing: 14,
runSpacing: 6,
children: [
_LegendChip(
color: theme.colorScheme.primary,
label: 'Quiet',
),
const _LegendChip(color: Colors.orange, label: 'Moderate'),
_LegendChip(
color: theme.colorScheme.error,
label: 'Busy',
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${rangeValues.start.toStringAsFixed(3)} MHz',
style: theme.textTheme.labelMedium,
),
Text(
'${rangeValues.end.toStringAsFixed(3)} MHz',
style: theme.textTheme.labelMedium,
),
],
),
RangeSlider(
values: rangeValues,
min: rangeMinMhz,
max: rangeMaxMhz,
divisions: (((rangeMaxMhz - rangeMinMhz) * 20).round()).clamp(
1,
400,
),
labels: RangeLabels(
rangeValues.start.toStringAsFixed(3),
rangeValues.end.toStringAsFixed(3),
),
onChanged: onRangeChanged,
),
if (selectableCandidates.isEmpty) ...[
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.surface.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Text(
scanSupported
? 'No scan results yet. Adjust the range and run a scan to populate candidate frequencies.'
: 'Spectrum preview only. This companion can display the configured span, but cannot scan for open channels.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
] else ...[
const SizedBox(height: 10),
DropdownButtonFormField<int>(
initialValue: selectedFrequencyKhz,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Candidate frequency',
border: OutlineInputBorder(),
helperText: 'Best frequencies for the current bandwidth',
),
items: selectableCandidates.map((candidate) {
return DropdownMenuItem<int>(
value: candidate.centerFrequencyKhz,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
overflow: TextOverflow.ellipsis,
),
Text(
'${candidate.occupancyPercent}% occupied | peak ${candidate.peakRssiDbm} dBm',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
overflow: TextOverflow.ellipsis,
),
],
),
);
}).toList(),
selectedItemBuilder: (context) {
return selectableCandidates.map((candidate) {
return Align(
alignment: Alignment.centerLeft,
child: Text(
'${candidate.centerFrequencyMhz.toStringAsFixed(3)} MHz',
overflow: TextOverflow.ellipsis,
),
);
}).toList();
},
onChanged: onCandidateChanged,
),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: selectedFrequencyKhz == null
? null
: onApplySelected,
icon: const Icon(Icons.north_east),
label: const Text('Use selected frequency'),
),
),
],
],
),
);
}
}
class _LegendChip extends StatelessWidget {
final Color color;
final String label;
const _LegendChip({required this.color, required this.label});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(label, style: Theme.of(context).textTheme.labelMedium),
],
);
}
}
class _FrequencyRangePreview extends StatelessWidget {
final double minMhz;
final double maxMhz;
final RangeValues selectedRange;
final double selectedBandwidthKhz;
final int? selectedFrequencyKhz;
final List<SpectrumScanCandidate> candidates;
const _FrequencyRangePreview({
required this.minMhz,
required this.maxMhz,
required this.selectedRange,
required this.selectedBandwidthKhz,
required this.selectedFrequencyKhz,
required this.candidates,
});
double _positionFor(double mhz) {
final span = maxMhz - minMhz;
if (span <= 0) return 0;
return ((mhz - minMhz) / span).clamp(0.0, 1.0);
}
Color _candidateColor(BuildContext context, int occupancyPercent) {
final scheme = Theme.of(context).colorScheme;
if (occupancyPercent <= 10) return scheme.primary;
if (occupancyPercent <= 35) return Colors.orange;
return scheme.error;
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final spanMhz = maxMhz - minMhz;
final selectedFreqMhz = selectedFrequencyKhz != null
? selectedFrequencyKhz! / 1000.0
: null;
final bwMhz = selectedBandwidthKhz / 1000.0;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final rangeLeft = _positionFor(selectedRange.start) * width;
final rangeRight = _positionFor(selectedRange.end) * width;
double? bwLeft;
double? bwWidth;
if (selectedFreqMhz != null && spanMhz > 0) {
bwLeft = _positionFor(selectedFreqMhz - (bwMhz / 2)) * width;
final bwRight = _positionFor(selectedFreqMhz + (bwMhz / 2)) * width;
bwWidth = (bwRight - bwLeft).clamp(4.0, width);
}
return Container(
height: 108,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
gradient: LinearGradient(
colors: [
scheme.surface,
scheme.surfaceContainerHighest.withValues(alpha: 0.9),
],
),
border: Border.all(color: scheme.outlineVariant),
),
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 12,
),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(
colors: [
scheme.primary.withValues(alpha: 0.12),
scheme.tertiary.withValues(alpha: 0.08),
scheme.primary.withValues(alpha: 0.12),
],
),
),
),
),
),
Positioned(
left: rangeLeft,
top: 12,
width: (rangeRight - rangeLeft).clamp(8.0, width),
height: 56,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: scheme.primary.withValues(alpha: 0.20),
border: Border.all(color: scheme.primary),
),
),
),
if (bwLeft != null && bwWidth != null)
Positioned(
left: bwLeft,
top: 28,
width: bwWidth,
height: 24,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: scheme.tertiary.withValues(alpha: 0.32),
border: Border.all(color: scheme.tertiary),
),
),
),
for (final candidate in candidates)
Positioned(
left: (_positionFor(candidate.centerFrequencyMhz) * width)
.clamp(10.0, width - 18.0),
top: 72,
child: Column(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _candidateColor(
context,
candidate.occupancyPercent,
),
),
),
const SizedBox(height: 4),
Text(
candidate.centerFrequencyMhz.toStringAsFixed(3),
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
],
),
);
},
);
}
}

View File

@@ -1,108 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
/// Overlay widget that displays controls for download area selection.
/// The actual polygon should be rendered inside FlutterMap's children.
class DownloadAreaOverlay extends StatelessWidget {
final LatLngBounds bounds;
final VoidCallback onConfirm;
final VoidCallback onCancel;
const DownloadAreaOverlay({
super.key,
required this.bounds,
required this.onConfirm,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Control buttons at the top
Positioned(
top: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Download Area Selection',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'The blue rectangle shows the area to be downloaded. '
'To change the area, tap Cancel and select download again.',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: onCancel,
icon: const Icon(Icons.close),
label: const Text('Cancel'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: onConfirm,
icon: const Icon(Icons.check),
label: const Text('Confirm'),
),
),
],
),
],
),
),
),
),
// Area info at the bottom
Positioned(
bottom: 16,
left: 16,
right: 16,
child: Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Area Bounds',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'N: ${bounds.north.toStringAsFixed(4)}° '
'S: ${bounds.south.toStringAsFixed(4)}°',
style: Theme.of(context).textTheme.bodySmall,
),
Text(
'E: ${bounds.east.toStringAsFixed(4)}° '
'W: ${bounds.west.toStringAsFixed(4)}°',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
),
],
);
}
}

View File

@@ -8,14 +8,8 @@ import '../../l10n/app_localizations.dart';
class DrawingLayer extends StatelessWidget {
final List<MapDrawing> drawings;
final MapDrawing? previewDrawing;
final bool isSimpleMode;
const DrawingLayer({
super.key,
required this.drawings,
this.previewDrawing,
this.isSimpleMode = false,
});
const DrawingLayer({super.key, required this.drawings, this.previewDrawing});
@override
Widget build(BuildContext context) {
@@ -48,8 +42,7 @@ class DrawingLayer extends StatelessWidget {
strokeWidth = 4.0;
} else if (drawing.isReceived) {
// Received drawing from another node
// In simple mode: solid (opacity 1.0), in normal mode: translucent (0.7)
opacity = isSimpleMode ? 1.0 : 0.7;
opacity = 1.0;
strokeWidth = 3.0;
} else {
// Local drawing (solid line, normal thickness)
@@ -87,7 +80,6 @@ class DrawingMarkersLayer extends StatelessWidget {
final Function(String drawingId)? onDeleteDrawing;
final Function(MapDrawing drawing)? onTapDrawing;
final bool showDeleteButtons;
final bool isSimpleMode;
const DrawingMarkersLayer({
super.key,
@@ -95,7 +87,6 @@ class DrawingMarkersLayer extends StatelessWidget {
this.onDeleteDrawing,
this.onTapDrawing,
this.showDeleteButtons = false,
this.isSimpleMode = false,
});
@override
@@ -132,73 +123,7 @@ class DrawingMarkersLayer extends StatelessWidget {
),
],
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 20,
),
),
),
),
);
} else if (drawing.isReceived && drawing.senderName != null && !isSimpleMode) {
// Show sender badge for received drawings (when not in drawing mode and not in simple mode)
// Make it tappable if message ID is available
markers.add(
Marker(
point: centerPoint,
width: 120,
height: 30,
child: GestureDetector(
onTap: drawing.messageId != null && onTapDrawing != null
? () => onTapDrawing!(drawing)
: null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: drawing.color.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white, width: 1.5),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.person,
color: Colors.white,
size: 14,
),
const SizedBox(width: 4),
Flexible(
child: Text(
drawing.senderName!,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
// Add indicator that this is tappable
if (drawing.messageId != null && onTapDrawing != null) ...[
const SizedBox(width: 4),
const Icon(
Icons.arrow_forward_ios,
color: Colors.white,
size: 10,
),
],
],
),
child: const Icon(Icons.close, color: Colors.white, size: 20),
),
),
),
@@ -236,9 +161,7 @@ class DrawingMarkersLayer extends StatelessWidget {
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.deleteDrawing),
content: Text(
'Delete this ${drawing.type.name}?',
),
content: Text('Delete this ${drawing.type.name}?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@@ -249,9 +172,7 @@ class DrawingMarkersLayer extends StatelessWidget {
Navigator.pop(context);
onDeleteDrawing?.call(drawing.id);
},
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.delete),
),
],

View File

@@ -849,6 +849,7 @@ class DrawingToolbar extends StatelessWidget {
contactPublicKey: room.publicKey,
text: message,
messageId: messageId,
contact: room,
);
debugPrint(' ✅ Sent successfully');

View File

@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/map_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/app_provider.dart';
import '../../services/gpx_service.dart';
import '../../services/trail_color_service.dart';
import '../../l10n/app_localizations.dart';
@@ -13,10 +11,11 @@ class TrailControls extends StatelessWidget {
void _showTrailMenu(BuildContext context) {
final mapProvider = Provider.of<MapProvider>(context, listen: false);
final contactsProvider = Provider.of<ContactsProvider>(context, listen: false);
final appProvider = Provider.of<AppProvider>(context, listen: false);
final contactsProvider = Provider.of<ContactsProvider>(
context,
listen: false,
);
final l10n = AppLocalizations.of(context)!;
final isSimpleMode = appProvider.isSimpleMode;
// Get contacts with trails (advertHistory >= 2 points)
final contactsWithTrails = contactsProvider.contactsWithLocation
@@ -34,258 +33,232 @@ class TrailControls extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.timeline, size: 24),
const SizedBox(width: 12),
Text(
l10n.locationTrail,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 20),
// Trail visibility toggle
SwitchListTile(
secondary: const Icon(Icons.visibility),
title: Text(l10n.showTrailOnMap),
subtitle: Text(
mapProvider.isTrailVisible
? l10n.trailVisible
: l10n.trailHiddenRecording,
),
value: mapProvider.isTrailVisible,
onChanged: (value) {
mapProvider.toggleTrailVisibility();
setModalState(() {}); // Update modal UI
},
),
const Divider(),
const SizedBox(height: 8),
// Trail stats
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
Row(
children: [
_buildStatRow(
icon: Icons.straighten,
label: l10n.distance,
value: _formatDistance(mapProvider.totalTrailDistance),
),
const SizedBox(height: 8),
_buildStatRow(
icon: Icons.access_time,
label: l10n.duration,
value: _formatDuration(mapProvider.trailDuration),
),
const SizedBox(height: 8),
_buildStatRow(
icon: Icons.place,
label: l10n.points,
value: '${mapProvider.currentTrail!.points.length}',
const Icon(Icons.timeline, size: 24),
const SizedBox(width: 12),
Text(
l10n.locationTrail,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 20),
const SizedBox(height: 16),
// GPX Export/Import buttons (hidden in simple mode)
if (!isSimpleMode) ...[
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
ElevatedButton.icon(
onPressed: () async {
final success = await GpxService.exportTrailToFile(mapProvider.currentTrail!);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(success
? l10n.trailExportedSuccessfully
: l10n.failedToExportTrail),
backgroundColor: success ? Colors.green : Colors.red,
),
);
}
// Trail visibility toggle
SwitchListTile(
secondary: const Icon(Icons.visibility),
title: Text(l10n.showTrailOnMap),
subtitle: Text(
mapProvider.isTrailVisible
? l10n.trailVisible
: l10n.trailHiddenRecording,
),
value: mapProvider.isTrailVisible,
onChanged: (value) {
mapProvider.toggleTrailVisibility();
setModalState(() {}); // Update modal UI
},
icon: const Icon(Icons.upload),
label: Text(l10n.exportTrailToGpx),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
const Divider(),
const SizedBox(height: 8),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () async {
try {
final trail = await GpxService.importTrailFromFile();
if (trail != null && context.mounted) {
_showImportDialog(context, mapProvider, trail, l10n);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.failedToImportTrail(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
},
icon: const Icon(Icons.download),
label: Text(l10n.importTrailFromGpx),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
const SizedBox(height: 16),
],
// Clear trail button
if (mapProvider.currentTrail != null && mapProvider.currentTrail!.points.isNotEmpty)
ElevatedButton.icon(
onPressed: () {
_showClearConfirmation(context, mapProvider, l10n);
},
icon: const Icon(Icons.delete_outline),
label: Text(l10n.clearTrail),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
),
),
// No trail message
if (mapProvider.currentTrail == null || mapProvider.currentTrail!.points.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
children: [
const Icon(Icons.timeline, size: 48, color: Colors.grey),
const SizedBox(height: 8),
Text(
l10n.noTrailRecorded,
style: const TextStyle(
color: Colors.grey,
fontSize: 16,
),
// Trail stats
if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.blue.withValues(alpha: 0.3),
),
const SizedBox(height: 8),
Text(
l10n.startTrackingToRecord,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
// Contact Trails Section
Row(
children: [
const Icon(Icons.people, size: 20),
const SizedBox(width: 8),
Text(
l10n.contactTrails,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
// Show All Contact Trails toggle
SwitchListTile(
secondary: const Icon(Icons.route),
title: Text(l10n.showAllContactTrails),
subtitle: Text(contactsWithTrails.isEmpty
? l10n.noContactsWithLocationHistory
: mapProvider.showAllContactTrails
? l10n.showingTrailsForContacts(contactsWithTrails.length)
: l10n.individualContactTrails),
value: mapProvider.showAllContactTrails,
onChanged: contactsWithTrails.isNotEmpty
? (value) {
mapProvider.toggleAllContactTrails();
setModalState(() {}); // Update modal UI
}
: null, // Disable if no contacts with trails
),
// Individual contact trails (when "show all" is OFF)
if (!mapProvider.showAllContactTrails && contactsWithTrails.isNotEmpty)
ExpansionTile(
title: Text(l10n.individualContactTrails),
initiallyExpanded: false,
children: contactsWithTrails.map((contact) {
final trailColor = TrailColorService.getTrailColor(contact);
final isVisible = mapProvider.isContactPathVisible(contact.publicKeyHex);
return SwitchListTile(
// Color indicator with emoji
secondary: Row(
mainAxisSize: MainAxisSize.min,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (contact.roleEmoji != null)
Text(contact.roleEmoji!, style: const TextStyle(fontSize: 18)),
const SizedBox(width: 4),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: trailColor,
border: Border.all(color: Colors.white, width: 2),
borderRadius: BorderRadius.circular(3),
_buildStatRow(
icon: Icons.straighten,
label: l10n.distance,
value: _formatDistance(
mapProvider.totalTrailDistance,
),
),
const SizedBox(height: 8),
_buildStatRow(
icon: Icons.access_time,
label: l10n.duration,
value: _formatDuration(mapProvider.trailDuration),
),
const SizedBox(height: 8),
_buildStatRow(
icon: Icons.place,
label: l10n.points,
value: '${mapProvider.currentTrail!.points.length}',
),
],
),
title: Text(contact.displayName),
subtitle: Text('${contact.advertHistory.length} points'),
value: isVisible,
onChanged: (value) {
mapProvider.toggleContactPath(contact.publicKeyHex);
setModalState(() {}); // Update modal UI
),
const SizedBox(height: 16),
// Clear trail button
if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.isNotEmpty)
ElevatedButton.icon(
onPressed: () {
_showClearConfirmation(context, mapProvider, l10n);
},
);
}).toList(),
),
icon: const Icon(Icons.delete_outline),
label: Text(l10n.clearTrail),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
),
),
const SizedBox(height: 8),
// No trail message
if (mapProvider.currentTrail == null ||
mapProvider.currentTrail!.points.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
children: [
const Icon(
Icons.timeline,
size: 48,
color: Colors.grey,
),
const SizedBox(height: 8),
Text(
l10n.noTrailRecorded,
style: const TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
l10n.startTrackingToRecord,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
),
// Close button
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.close),
),
],
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 8),
// Contact Trails Section
Row(
children: [
const Icon(Icons.people, size: 20),
const SizedBox(width: 8),
Text(
l10n.contactTrails,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 12),
// Show All Contact Trails toggle
SwitchListTile(
secondary: const Icon(Icons.route),
title: Text(l10n.showAllContactTrails),
subtitle: Text(
contactsWithTrails.isEmpty
? l10n.noContactsWithLocationHistory
: mapProvider.showAllContactTrails
? l10n.showingTrailsForContacts(
contactsWithTrails.length,
)
: l10n.individualContactTrails,
),
value: mapProvider.showAllContactTrails,
onChanged: contactsWithTrails.isNotEmpty
? (value) {
mapProvider.toggleAllContactTrails();
setModalState(() {}); // Update modal UI
}
: null, // Disable if no contacts with trails
),
// Individual contact trails (when "show all" is OFF)
if (!mapProvider.showAllContactTrails &&
contactsWithTrails.isNotEmpty)
ExpansionTile(
title: Text(l10n.individualContactTrails),
initiallyExpanded: false,
children: contactsWithTrails.map((contact) {
final trailColor = TrailColorService.getTrailColor(
contact,
);
final isVisible = mapProvider.isContactPathVisible(
contact.publicKeyHex,
);
return SwitchListTile(
// Color indicator with emoji
secondary: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.roleEmoji != null)
Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 18),
),
const SizedBox(width: 4),
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: trailColor,
border: Border.all(
color: Colors.white,
width: 2,
),
borderRadius: BorderRadius.circular(3),
),
),
],
),
title: Text(contact.displayName),
subtitle: Text(
'${contact.advertHistory.length} points',
),
value: isVisible,
onChanged: (value) {
mapProvider.toggleContactPath(contact.publicKeyHex);
setModalState(() {}); // Update modal UI
},
);
}).toList(),
),
const SizedBox(height: 8),
// Close button
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.close),
),
],
),
),
),
@@ -293,7 +266,11 @@ class TrailControls extends StatelessWidget {
);
}
void _showClearConfirmation(BuildContext context, MapProvider mapProvider, AppLocalizations l10n) {
void _showClearConfirmation(
BuildContext context,
MapProvider mapProvider,
AppLocalizations l10n,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
@@ -318,50 +295,6 @@ class TrailControls extends StatelessWidget {
);
}
void _showImportDialog(BuildContext context, MapProvider mapProvider, trail, AppLocalizations l10n) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.importTrail),
content: Text(l10n.importTrailQuestion(trail.points.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
mapProvider.setImportedTrail(trail);
Navigator.pop(context); // Close dialog
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.trailImported(trail.points.length)),
backgroundColor: Colors.green,
),
);
},
child: Text(l10n.viewAlongside),
),
TextButton(
onPressed: () {
mapProvider.replaceCurrentTrailWithImport(trail);
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Close bottom sheet
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.trailReplaced(trail.points.length)),
backgroundColor: Colors.green,
),
);
},
style: TextButton.styleFrom(foregroundColor: Colors.blue),
child: Text(l10n.replaceCurrent),
),
],
),
);
}
Widget _buildStatRow({
required IconData icon,
required String label,
@@ -373,18 +306,12 @@ class TrailControls extends StatelessWidget {
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14,
),
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
),
const Spacer(),
Text(
value,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
],
);

View File

@@ -3,10 +3,13 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/image_provider.dart' as ip;
import '../../providers/messages_provider.dart';
import '../../utils/image_message_parser.dart';
import '../../utils/transmission_target_resolver.dart';
import 'transfer_timeout.dart';
@@ -32,7 +35,9 @@ class ImageMessageBubble extends StatefulWidget {
class _ImageMessageBubbleState extends State<ImageMessageBubble> {
static const int _maxFetchHops = 3;
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
bool _isRequesting = false;
bool _isPartialRequest = false;
String? _errorText;
Timer? _requestTimeoutTimer;
@@ -58,6 +63,11 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return Consumer<ip.ImageProvider>(
builder: (context, imageProvider, _) {
final transferCount = context.select<MessagesProvider, int>(
(provider) => provider.transferCountForSession(
imageSessionId: envelope.sessionId,
),
);
final contactsProvider = context.read<ContactsProvider>();
final session = imageProvider.session(envelope.sessionId);
final sender = TransmissionTargetResolver.resolveLocalTarget(
@@ -65,11 +75,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope.senderKey6,
senderName: widget.message.senderName,
);
final effectivePathLen = sender != null && sender.outPathLen >= 0
? sender.outPathLen
final effectivePathLen = sender != null && sender.routeHasPath
? sender.routeHopCount
: widget.message.pathLen;
final isComplete = imageProvider.isComplete(envelope.sessionId);
final eta = imageProvider.estimateRemainingTransferTime(
@@ -81,6 +90,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (mounted) {
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = null;
});
}
@@ -90,6 +100,17 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
final received = session?.receivedCount ?? 0;
final total = session?.total ?? envelope.total;
final imageBytes = isComplete ? session?.imageBytes : null;
final fragmentPresence =
session?.fragments.map((fragment) => fragment != null).toList() ??
List<bool>.filled(total, false);
final isReceivingData =
!_isRequesting &&
!isComplete &&
_hasRecentInboundActivity(
lastReceivedAt: session?.lastFragmentAt,
received: received,
total: total,
);
return GestureDetector(
onTap: isComplete
@@ -109,8 +130,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
imageBytes: imageBytes,
isComplete: isComplete,
isRequesting: _isRequesting,
isReceivingData: isReceivingData,
received: received,
total: total,
fragmentPresence: fragmentPresence,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
@@ -124,6 +147,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
_statusText(
isComplete: isComplete,
isRequesting: _isRequesting,
isReceivingData: isReceivingData,
isPartialRequest: _isPartialRequest,
received: received,
total: total,
envelope: envelope,
@@ -134,6 +159,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
isSentByMe: widget.isSentByMe,
eta: eta,
pathLen: effectivePathLen,
transferCount: transferCount,
),
style: TextStyle(
fontSize: 11,
@@ -155,8 +181,10 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required Uint8List? imageBytes,
required bool isComplete,
required bool isRequesting,
required bool isReceivingData,
required int received,
required int total,
required List<bool> fragmentPresence,
required ImageEnvelope envelope,
required int? radioBw,
required int? radioSf,
@@ -179,19 +207,28 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
alignment: Alignment.center,
children: [
if (isRequesting) ...[
// Download progress ring.
SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(
value: total > 0 ? received / total : null,
strokeWidth: 3,
color: Theme.of(context).colorScheme.primary,
Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_PacketBlockProgress(
presence: fragmentPresence,
activeColor: Theme.of(context).colorScheme.primary,
highlightMissing: _isPartialRequest,
),
const SizedBox(height: 8),
Text(
'$received/$total',
style: const TextStyle(color: Colors.white, fontSize: 11),
),
],
),
),
Text(
'$received/$total',
style: const TextStyle(color: Colors.white, fontSize: 11),
),
Positioned(
top: 8,
@@ -228,16 +265,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
] else ...[
// Tap-to-load icon.
IconButton(
onPressed: () => _requestAndFetch(
envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: pathLen,
onPressed: isReceivingData
? null
: () => _requestAndFetch(
envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: pathLen,
),
icon: Icon(
isReceivingData
? Icons.downloading_rounded
: Icons.download_rounded,
size: 40,
),
icon: const Icon(Icons.download_rounded, size: 40),
color: Colors.white70,
tooltip: 'Load image',
tooltip: isReceivingData
? 'Image is already being received'
: 'Load image',
),
],
],
@@ -254,23 +300,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
int pathLen = 0,
}) async {
if (_isRequesting) return;
final conn = context.read<ConnectionProvider>();
final imageProvider = context.read<ip.ImageProvider>();
imageProvider.resumeIncomingSession(envelope.sessionId);
final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
final appProvider = context.read<AppProvider>();
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: conn.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope.senderKey6,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.',
@@ -278,6 +326,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.',
@@ -285,23 +334,94 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unreachable) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route did not respond to a path check. Sync contacts/path and try again.',
);
return;
}
final sender = resolution.target!;
if (sender.outPathLen >= 2) {
var sender = resolution.target!;
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
await conn.getContacts();
if (!mounted) return;
resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: conn.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender contact is unknown. Sync contacts first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
sender = resolution.target!;
routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route did not respond on the raw transport path.',
);
return;
}
}
if (!sender.routeSupportsLegacyRawTransport) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
);
return;
}
if (sender.routeHopCount >= 2) {
_showToast(
'Image fetch over ${sender.outPathLen} hops may take a while.',
'Image fetch over ${sender.routeHopCount} hops may take a while.',
);
}
setState(() => _errorText = null);
final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch image',
'Device key is unavailable.',
@@ -318,30 +438,31 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
final missing = imageProvider.missingFragmentIndices(envelope.sessionId);
final isPartialResume =
missing.isNotEmpty && missing.length < envelope.total;
setState(() {
_isRequesting = true;
_isPartialRequest = isPartialResume;
_errorText = null;
});
final request = isPartialResume
? ImageFetchRequest(
sessionId: envelope.sessionId,
want: 'missing',
missingIndices: missing,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
)
: ImageFetchRequest(
sessionId: envelope.sessionId,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
setState(() {
_isRequesting = true;
_errorText = null;
});
final payload = request.encodeBinary();
try {
debugPrint(
'📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
);
await conn.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
contactPathLen: sender.routeSignedPathLen,
payload: payload,
);
} catch (_) {
@@ -349,6 +470,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
_showToast('Image fetch failed to send request');
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = 'Image unavailable right now';
});
}
@@ -357,8 +479,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (!mounted) return;
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0
? sender.outPathLen
final effectivePathLen = sender.routeHasPath
? sender.routeHopCount
: pathLen;
final txEstimate = estimateImageTransmitDuration(
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
@@ -380,6 +502,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
_showToast('Image fetch timed out');
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = 'Image fetch timed out';
});
}
@@ -401,10 +524,19 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
_showToast('Image receive canceled');
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = 'Image receive canceled';
});
}
void _clearRequestState() {
if (!mounted) return;
setState(() {
_isRequesting = false;
_isPartialRequest = false;
});
}
Future<void> _showBlockingAlert(String title, String message) async {
if (!mounted) return;
_showToast('$title: $message');
@@ -426,6 +558,8 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
static String _statusText({
required bool isComplete,
required bool isRequesting,
required bool isReceivingData,
required bool isPartialRequest,
required int received,
required int total,
required ImageEnvelope envelope,
@@ -436,6 +570,7 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
required String? error,
required bool isSentByMe,
required Duration? eta,
required int transferCount,
}) {
final txEstimate = estimateImageTransmitDuration(
fragmentCount: envelope.total,
@@ -450,16 +585,25 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
if (error != null) return error;
if (isRequesting) {
final etaLabel = _formatEta(eta);
return '📥 Loading… $received/$total · $etaLabel · $txEstimateLabel';
final actionLabel = isPartialRequest
? '📥 Fetching missing fragments…'
: '📥 Loading…';
return '$actionLabel $received/$total · $etaLabel · $txEstimateLabel';
}
if (isReceivingData) {
final etaLabel = _formatEta(eta);
return '📥 Receiving… $received/$total · $etaLabel · $txEstimateLabel';
}
if (isComplete) {
final base =
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
return isSentByMe
? '$base · ${envelope.total} seg · $txEstimateLabel'
? '$base · ${envelope.total} seg · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '$base · $txEstimateLabel';
}
return '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel';
return isSentByMe
? '🖼️ ${envelope.width}×${envelope.height} · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '🖼️ Tap to load · ${envelope.width}×${envelope.height} · $txEstimateLabel';
}
static String _formatTransmitEstimate(Duration value) {
@@ -477,6 +621,22 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
return 'ETA ~${minutes}m ${seconds}s';
}
static String _formatTransferCount(int transferCount) {
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
}
bool _hasRecentInboundActivity({
required DateTime? lastReceivedAt,
required int received,
required int total,
}) {
if (lastReceivedAt == null || received <= 0 || received >= total) {
return false;
}
return DateTime.now().difference(lastReceivedAt) <=
_recentInboundActivityWindow;
}
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
showGeneralDialog<void>(
context: context,
@@ -523,3 +683,67 @@ class _ImageMessageBubbleState extends State<ImageMessageBubble> {
);
}
}
class _PacketBlockProgress extends StatelessWidget {
final List<bool> presence;
final Color activeColor;
final bool highlightMissing;
const _PacketBlockProgress({
required this.presence,
required this.activeColor,
this.highlightMissing = false,
});
@override
Widget build(BuildContext context) {
if (presence.isEmpty) {
return const SizedBox(width: 96, height: 12);
}
final bucketCount = presence.length <= 24 ? presence.length : 24;
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
final start = (bucketIndex * presence.length) ~/ bucketCount;
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
final safeEnd = end <= start ? start + 1 : end;
final slice = presence.sublist(start, safeEnd);
final received = slice.where((value) => value).length;
return slice.isEmpty ? 0.0 : received / slice.length;
});
final missingColor = highlightMissing
? Colors.amberAccent
: Colors.white.withValues(alpha: 0.14);
return SizedBox(
width: 120,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final fill in bucketFill)
Expanded(
child: Container(
height: 12,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: fill > 0
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
: missingColor.withValues(
alpha: highlightMissing ? 0.45 : 0.14,
),
borderRadius: BorderRadius.circular(2),
border: Border.all(
color: fill > 0
? Colors.white.withValues(alpha: 0.18)
: missingColor.withValues(
alpha: highlightMissing ? 0.7 : 0.18,
),
width: 0.5,
),
),
),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../utils/avatar_label_helper.dart';
import '../../utils/message_extensions.dart';
import '../common/contact_avatar.dart';
Widget buildMessageHeaderAvatar(
BuildContext context, {
required bool isOwnMessage,
required bool isChannelMessage,
required dynamic senderContact,
required String displayName,
}) {
if (isOwnMessage) {
return CircleAvatar(
radius: 10.5,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.account_circle,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
);
}
if (senderContact is Contact) {
return ContactAvatar(
contact: senderContact,
radius: 10.5,
displayName: displayName,
);
}
final background = isChannelMessage
? Colors.teal.withValues(alpha: 0.16)
: Theme.of(context).colorScheme.surfaceContainerHighest;
final foreground = isChannelMessage
? Colors.teal.shade800
: Theme.of(context).colorScheme.onSurfaceVariant;
return CircleAvatar(
radius: 10.5,
backgroundColor: background,
child: Text(
AvatarLabelHelper.buildLabel(displayName),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: foreground,
letterSpacing: -0.2,
),
),
);
}
Widget buildBubbleMetaFooter(
BuildContext context, {
required Message message,
required bool isSarMarker,
}) {
final metaColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
final items = <Widget>[];
final sentEchoLabel = message.isSentMessage && message.echoCount > 0
? '${message.echoCount} echo${message.echoCount == 1 ? '' : 'es'}'
: null;
if (!isSarMarker && sentEchoLabel != null) {
items.addAll([
Icon(Icons.hub_outlined, size: 11, color: metaColor),
const SizedBox(width: 3),
Text(
sentEchoLabel,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
Text(
'',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
]);
} else if (!isSarMarker && message.pathLen < 255) {
items.addAll([
Icon(Icons.alt_route, size: 11, color: metaColor),
const SizedBox(width: 3),
Text(
message.pathLen == 0 ? 'direct' : '${message.pathLen}hop',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
Text(
'',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
]);
}
items.add(
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: metaColor,
fontWeight: FontWeight.w500,
),
),
);
return Padding(
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
child: Align(
alignment: Alignment.centerRight,
child: Row(mainAxisSize: MainAxisSize.min, children: items),
),
);
}
Widget buildChannelHeaderPill(
BuildContext context, {
required String label,
IconData icon = Icons.campaign_outlined,
}) {
final labelColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 11,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
),
const SizedBox(width: 5),
Flexible(
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: labelColor,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Widget buildDirectHeaderCounterpart(
BuildContext context, {
required String label,
}) {
return buildChannelHeaderPill(
context,
label: label,
icon: Icons.alternate_email,
);
}

View File

@@ -0,0 +1,382 @@
import 'package:flutter/material.dart';
import '../../models/message.dart';
import '../../models/message_reception_details.dart';
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
switch (status) {
case MessageDeliveryStatus.sending:
return Icons.schedule;
case MessageDeliveryStatus.sent:
return Icons.done;
case MessageDeliveryStatus.delivered:
return Icons.done_all;
case MessageDeliveryStatus.failed:
return Icons.error_outline;
case MessageDeliveryStatus.received:
return Icons.inbox;
}
}
Color getDeliveryStatusColor(MessageDeliveryStatus status) {
switch (status) {
case MessageDeliveryStatus.sending:
return Colors.orange;
case MessageDeliveryStatus.sent:
return Colors.blue;
case MessageDeliveryStatus.delivered:
return Colors.green;
case MessageDeliveryStatus.failed:
return Colors.red;
case MessageDeliveryStatus.received:
return Colors.grey;
}
}
Widget buildChannelEchoStatus(BuildContext context, Message message) {
final hasEcho = message.echoCount > 0;
if (!hasEcho) {
return const SizedBox.shrink();
}
final statusColor = getDeliveryStatusColor(message.deliveryStatus);
final rssi = message.lastEchoRssiDbm;
final snr = message.lastEchoSnrRaw != null
? message.lastEchoSnrRaw!.toSigned(8) / 4.0
: null;
final quality = linkQualityLabel(rssi, snr);
final qualityColor = linkQualityColor(quality);
return Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.hub_outlined,
label: 'x${message.echoCount}',
color: statusColor,
),
if (message.expectedAckTag != null)
_techChip(
context,
icon: Icons.tag,
label:
'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}',
color: Colors.indigo,
),
_techChip(context, icon: Icons.bolt, label: quality, color: qualityColor),
if (message.lastEchoRssiDbm != null)
_signalCapsule(
context,
icon: Icons.network_cell,
label: message.lastEchoRssiDbm!.toString(),
filled: rssiScore(message.lastEchoRssiDbm!),
color: Colors.blueGrey,
),
if (message.lastEchoSnrRaw != null)
_signalCapsule(
context,
icon: Icons.graphic_eq,
label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(1),
filled: snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0),
color: Colors.teal,
),
],
);
}
bool shouldShowSentChannelStats(
Message message, {
required bool showReceivedStats,
}) {
if (!message.isSentMessage || !message.isChannelMessage) {
return false;
}
final hasSignalData =
message.echoCount > 0 ||
message.lastEchoRssiDbm != null ||
message.lastEchoSnrRaw != null ||
message.expectedAckTag != null;
return showReceivedStats && hasSignalData;
}
Widget buildReceivedSignalStatus(
BuildContext context,
Message message, {
MessageReceptionDetails? receptionDetails,
required int? rssiDbm,
required double? snrDb,
}) {
final hopLabel = hopDisplayLabel(message);
return Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.alt_route,
label: hopLabel,
color: Colors.indigo,
),
if (receptionDetails?.senderToReceiptMs != null)
_techChip(
context,
icon: Icons.schedule,
label: _formatMs(receptionDetails!.senderToReceiptMs!),
color: Colors.deepPurple,
),
if (receptionDetails?.estimatedTransmitMs != null)
_techChip(
context,
icon: Icons.timelapse,
label: '~${_formatMs(receptionDetails!.estimatedTransmitMs!)} tx',
color: Colors.blue,
),
if (receptionDetails?.postTransmitDelayMs != null)
_techChip(
context,
icon: Icons.hourglass_bottom,
label: '+${_formatMs(receptionDetails!.postTransmitDelayMs!)} lag',
color: Colors.orange,
),
if (receptionDetails?.pathBytesHex != null)
_techChip(
context,
icon: Icons.route,
label: receptionDetails!.pathBytesHex!,
color: Colors.brown,
),
if (rssiDbm != null || snrDb != null) ...[
_techChip(
context,
icon: Icons.bolt,
label: linkQualityLabel(rssiDbm, snrDb),
color: linkQualityColor(linkQualityLabel(rssiDbm, snrDb)),
),
if (rssiDbm != null)
_signalCapsule(
context,
icon: Icons.network_cell,
label: '$rssiDbm',
filled: rssiScore(rssiDbm),
color: Colors.blueGrey,
),
if (snrDb != null)
_signalCapsule(
context,
icon: Icons.graphic_eq,
label: snrDb.toStringAsFixed(1),
filled: snrScore(snrDb),
color: Colors.teal,
),
],
],
);
}
Widget buildSentDirectSignalStatus(
BuildContext context,
Message message, {
required int roundTripTimeMs,
required Duration txEstimate,
}) {
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
estimatedTransmitMs: txEstimate > Duration.zero
? txEstimate.inMilliseconds
: null,
senderToReceiptMs: roundTripTimeMs,
);
final postTransmitDelayMs = estimatedTransmitMs != null
? (roundTripTimeMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
: null;
return Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.alt_route,
label: hopDisplayLabel(message),
color: Colors.indigo,
),
_techChip(
context,
icon: Icons.schedule,
label: _formatMs(roundTripTimeMs),
color: Colors.deepPurple,
),
if (estimatedTransmitMs != null)
_techChip(
context,
icon: Icons.timelapse,
label: '~${_formatMs(estimatedTransmitMs)} tx',
color: Colors.blue,
),
if (postTransmitDelayMs != null)
_techChip(
context,
icon: Icons.hourglass_bottom,
label: '+${_formatMs(postTransmitDelayMs)} lag',
color: Colors.orange,
),
if (message.retryAttempt > 0)
_techChip(
context,
icon: Icons.refresh,
label: 'retry ${message.retryAttempt}/3',
color: Colors.redAccent,
),
if (message.suggestedTimeoutMs != null)
_techChip(
context,
icon: Icons.timer_outlined,
label: 'timeout ${_formatMs(message.suggestedTimeoutMs!)}',
color: Colors.blueGrey,
),
if (message.usedFloodFallback)
_techChip(
context,
icon: Icons.waves,
label: 'flood fallback',
color: Colors.teal,
)
else if (message.expectedAckTag != null)
_techChip(
context,
icon: Icons.route,
label: 'direct ACK',
color: Colors.indigo,
),
],
);
}
String _formatMs(int value) {
if (value >= 60000) {
final minutes = value ~/ 60000;
final seconds = (value % 60000) ~/ 1000;
return '${minutes}m ${seconds}s';
}
if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)}s';
}
return '${value}ms';
}
String hopDisplayLabel(Message message) {
if (message.pathLen == 0) return 'Direct';
if (message.pathLen >= 255 && message.isContactMessage) return 'Direct';
if (message.pathLen >= 255) return 'Unknown';
return '${message.pathLen} hop${message.pathLen == 1 ? '' : 's'}';
}
Widget _techChip(
BuildContext context, {
required IconData icon,
required String label,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
);
}
Widget _signalCapsule(
BuildContext context, {
required IconData icon,
required String label,
required int filled,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final active = i < filled;
return Container(
width: 3,
height: (4 + i).toDouble(),
margin: const EdgeInsets.symmetric(horizontal: 0.5),
decoration: BoxDecoration(
color: active ? color : color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(1),
),
);
}),
),
const SizedBox(width: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
);
}
int rssiScore(int rssiDbm) => ((rssiDbm + 120) / 10).round().clamp(0, 5);
int snrScore(double snrDb) => ((snrDb + 5.0) / 5.0).round().clamp(0, 5);
String linkQualityLabel(int? rssiDbm, double? snrDb) {
var score = 0;
if (rssiDbm != null) score += rssiScore(rssiDbm);
if (snrDb != null) score += snrScore(snrDb);
if (score >= 8) return 'Excellent';
if (score >= 6) return 'Good';
if (score >= 4) return 'Fair';
return 'Weak';
}
Color linkQualityColor(String quality) {
switch (quality) {
case 'Excellent':
return Colors.green;
case 'Good':
return Colors.lightGreen;
case 'Fair':
return Colors.orange;
default:
return Colors.redAccent;
}
}

View File

@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
import '../../models/ble_packet_log.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../services/mesh_map_nodes_service.dart';
class MessageTraceSheet extends StatefulWidget {
@@ -30,7 +31,7 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
Future<_TraceResult> _loadTrace() async {
final connectionProvider = context.read<ConnectionProvider>();
final nodes = await MeshMapNodesService.fetchNodes();
final contactsProvider = context.read<ContactsProvider>();
final packetPath = _extractPathFromPacketLogs(
logs: connectionProvider.bleService.packetLogs,
message: widget.message,
@@ -41,45 +42,27 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
? _toPrefixHex(widget.message.recipientPublicKey)
: _toPrefixHex(connectionProvider.deviceInfo.publicKey);
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
if (packetPath != null && packetPath.isNotEmpty) {
final matched = _matchNodesFromPathHashes(
nodes: nodes,
pathHashes: packetPath,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
return _TraceResult(
mode: TraceMode.packetPath,
sender: senderNode,
recipient: recipientNode,
pathHashes: packetPath,
matchedPathNodes: matched,
);
final localNodes = _localNodesFromContacts(contactsProvider);
var trace = _buildTraceResult(
nodes: localNodes,
packetPath: packetPath,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
if (_isCompleteTrace(trace, expectedRelayCount: math.max(0, widget.message.pathLen))) {
return trace;
}
// Fallback when packet path is unavailable.
final inferred = _inferRelaysFromHopCount(
nodes: nodes,
sender: senderNode,
recipient: recipientNode,
relayCount: math.max(0, widget.message.pathLen),
final remoteNodes = await MeshMapNodesService.fetchNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
final matchedPathNodes = <MeshMapNode?>[
if (senderNode != null) senderNode,
...inferred,
if (recipientNode != null) recipientNode,
];
return _TraceResult(
mode: TraceMode.hopCountInference,
sender: senderNode,
recipient: recipientNode,
pathHashes: const [],
matchedPathNodes: matchedPathNodes,
trace = _buildTraceResult(
nodes: _mergeNodes(localNodes, remoteNodes),
packetPath: packetPath,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
return trace;
}
@override
@@ -107,12 +90,16 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
}
final trace = snapshot.data!;
final mapPoints = trace.matchedPathNodes
.whereType<MeshMapNode>()
final routeEntries = _displayRouteEntries(trace);
final concretePathNodes = routeEntries
.where((entry) => entry.node != null)
.map((entry) => entry.node!)
.toList();
final mapPoints = concretePathNodes
.map((n) => LatLng(n.latitude, n.longitude))
.toList();
final hasMapPath = mapPoints.length >= 2;
final relayNodes = _relayNodes(trace.matchedPathNodes);
final relayNodes = _relayNodes(trace);
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
@@ -166,30 +153,36 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
child: hasMapPath
? flutter_map.FlutterMap(
options: flutter_map.MapOptions(
initialCameraFit: flutter_map.CameraFit.bounds(
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
padding: const EdgeInsets.all(28),
),
initialCameraFit:
flutter_map.CameraFit.bounds(
bounds:
flutter_map
.LatLngBounds.fromPoints(
mapPoints,
),
padding: const EdgeInsets.all(28),
),
),
children: [
flutter_map.TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.meshcore.sar',
userAgentPackageName:
'com.meshcore.sar',
),
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: mapPoints,
strokeWidth: 4,
color: Theme.of(context).colorScheme.primary,
color: Theme.of(
context,
).colorScheme.primary,
),
],
),
flutter_map.MarkerLayer(
markers: trace.matchedPathNodes
.whereType<MeshMapNode>()
.toList()
markers: concretePathNodes
.asMap()
.entries
.map(
@@ -202,12 +195,11 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
height: 34,
child: CircleAvatar(
radius: 16,
backgroundColor: entry.key == 0
backgroundColor:
entry.key == 0
? Colors.green
: (entry.key ==
trace
.matchedPathNodes
.whereType<MeshMapNode>()
concretePathNodes
.length -
1
? Colors.red
@@ -216,7 +208,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontWeight:
FontWeight.bold,
fontSize: 11,
),
),
@@ -228,13 +221,57 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
],
)
: const Center(
child: Text('Not enough geolocated nodes to draw path'),
child: Text(
'Not enough geolocated nodes to draw path',
),
),
),
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Route',
style: Theme.of(context).textTheme.titleMedium,
),
),
if (routeEntries.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No named nodes could be matched for this trace.',
),
),
...routeEntries.asMap().entries.map(
(entry) => ListTile(
leading: CircleAvatar(
radius: 14,
backgroundColor: entry.key == 0
? Colors.green
: (entry.key == routeEntries.length - 1
? Colors.red
: Colors.blue),
child: Text(
'${entry.key + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
),
title: Text(entry.value.label),
subtitle: Text(
'${_routeRoleLabel(entry.key, routeEntries.length)}${entry.value.keyLabel == null ? '' : '${entry.value.keyLabel}'}',
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
@@ -244,8 +281,13 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
),
if (relayNodes.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text('No relay nodes could be matched for this message.'),
padding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'No relay nodes could be matched for this message.',
),
),
...relayNodes.map(
(node) => ListTile(
@@ -268,12 +310,71 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
);
}
List<MeshMapNode> _relayNodes(List<MeshMapNode?> path) {
final concrete = path.whereType<MeshMapNode>().toList();
List<MeshMapNode> _relayNodes(_TraceResult trace) {
final concrete = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
if (concrete.isEmpty) return const [];
if (trace.mode == TraceMode.packetPath) {
if (concrete.length <= 1) return const [];
return concrete.sublist(1);
}
if (concrete.length <= 2) return const [];
return concrete.sublist(1, concrete.length - 1);
}
List<_RouteDisplayEntry> _displayRouteEntries(_TraceResult trace) {
final pathNodes = trace.matchedPathNodes.whereType<MeshMapNode>().toList();
if (pathNodes.isEmpty) {
return [
if (trace.sender != null) _RouteDisplayEntry.fromNode(trace.sender!),
if (trace.recipient != null &&
trace.recipient!.publicKey != trace.sender?.publicKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
];
}
if (trace.mode == TraceMode.packetPath) {
final entries = trace.matchedPathNodes.asMap().entries.map((entry) {
final hashHex = trace.pathHashes[entry.key]
.toRadixString(16)
.padLeft(2, '0');
return _RouteDisplayEntry(
node: entry.value,
label: entry.value?.name ?? 'Unknown',
keyLabel: entry.value != null
? _prefixKeyLabel(entry.value!.publicKey)
: hashHex,
);
}).toList();
final lastKey = pathNodes.last.publicKey;
return [
...entries,
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
];
}
final firstKey = pathNodes.first.publicKey;
final lastKey = pathNodes.last.publicKey;
return [
if (trace.sender != null && trace.sender!.publicKey != firstKey)
_RouteDisplayEntry.fromNode(trace.sender!),
...pathNodes.map(_RouteDisplayEntry.fromNode),
if (trace.recipient != null && trace.recipient!.publicKey != lastKey)
_RouteDisplayEntry.fromNode(trace.recipient!),
];
}
String _prefixKeyLabel(String publicKey) =>
publicKey.substring(0, math.min(12, publicKey.length));
String _routeRoleLabel(int index, int total) {
if (index == 0) return 'Sender';
if (index == total - 1) return 'Recipient';
return 'Relay';
}
String? _toPrefixHex(List<int>? key) {
if (key == null || key.isEmpty) return null;
final take = key.length < 6 ? key.length : 6;
@@ -286,19 +387,112 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
MeshMapNode? _bestNodeForPrefix(List<MeshMapNode> nodes, String? prefixHex) {
if (prefixHex == null || prefixHex.isEmpty) return null;
final matches = nodes
.where((n) => n.publicKey.startsWith(prefixHex))
.toList()
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
final matches =
nodes.where((n) => n.publicKey.startsWith(prefixHex)).toList()
..sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return matches.isEmpty ? null : matches.first;
}
List<MeshMapNode> _localNodesFromContacts(ContactsProvider contactsProvider) {
return contactsProvider.contactsWithLocation
.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return MeshMapNode(
type: contact.type.index,
name: contact.displayName,
publicKey: contact.publicKeyHex.toLowerCase(),
latitude: location.latitude,
longitude: location.longitude,
updatedAtMs: contact.lastAdvert * 1000,
);
})
.whereType<MeshMapNode>()
.toList();
}
List<MeshMapNode> _mergeNodes(
List<MeshMapNode> preferred,
List<MeshMapNode> fallback,
) {
final merged = <String, MeshMapNode>{};
for (final node in fallback) {
merged[node.publicKey] = node;
}
for (final node in preferred) {
merged[node.publicKey] = node;
}
return merged.values.toList();
}
_TraceResult _buildTraceResult({
required List<MeshMapNode> nodes,
required List<int>? packetPath,
required String? senderPrefix,
required String? recipientPrefix,
}) {
final senderNode = _bestNodeForPrefix(nodes, senderPrefix);
final recipientNode = _bestNodeForPrefix(nodes, recipientPrefix);
if (packetPath != null && packetPath.isNotEmpty) {
final matched = _matchNodesFromPathHashes(
nodes: nodes,
pathHashes: packetPath,
senderPrefix: senderPrefix,
recipientPrefix: recipientPrefix,
);
return _TraceResult(
mode: TraceMode.packetPath,
sender: senderNode,
recipient: recipientNode,
pathHashes: packetPath,
matchedPathNodes: matched,
);
}
final inferred = _inferRelaysFromHopCount(
nodes: nodes,
sender: senderNode,
recipient: recipientNode,
relayCount: math.max(0, widget.message.pathLen),
);
final matchedPathNodes = <MeshMapNode?>[
if (senderNode != null) senderNode,
...inferred,
if (recipientNode != null) recipientNode,
];
return _TraceResult(
mode: TraceMode.hopCountInference,
sender: senderNode,
recipient: recipientNode,
pathHashes: const [],
matchedPathNodes: matchedPathNodes,
);
}
bool _isCompleteTrace(_TraceResult trace, {required int expectedRelayCount}) {
if (trace.sender == null || trace.recipient == null) {
return false;
}
if (trace.mode == TraceMode.packetPath) {
return trace.matchedPathNodes.length == trace.pathHashes.length &&
trace.matchedPathNodes.every((node) => node != null);
}
final concreteCount = trace.matchedPathNodes.whereType<MeshMapNode>().length;
return concreteCount >= expectedRelayCount + 2;
}
List<int>? _extractPathFromPacketLogs({
required List<BlePacketLog> logs,
required Message message,
}) {
if (message.pathLen <= 0 || message.pathLen >= 255) return null;
final expectedPayloadType = message.messageType == MessageType.channel ? 0x05 : 0x02;
final expectedPayloadType = message.messageType == MessageType.channel
? 0x05
: 0x02;
BlePacketLog? bestLog;
var bestDeltaMs = 999999999;
@@ -313,7 +507,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
if (pathLen != message.pathLen) continue;
if (raw.length < 5 + pathLen) continue;
final deltaMs = (log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
final deltaMs =
(log.timestamp.difference(message.receivedAt).inMilliseconds).abs();
if (deltaMs < bestDeltaMs) {
bestDeltaMs = deltaMs;
bestLog = log;
@@ -335,7 +530,9 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
final result = <MeshMapNode?>[];
for (var i = 0; i < pathHashes.length; i++) {
final hashHex = pathHashes[i].toRadixString(16).padLeft(2, '0');
final candidates = nodes.where((n) => n.publicKey.startsWith(hashHex)).toList();
final candidates = nodes
.where((n) => n.publicKey.startsWith(hashHex))
.toList();
if (candidates.isEmpty) {
result.add(null);
continue;
@@ -343,13 +540,10 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
List<MeshMapNode> filtered = candidates;
if (i == 0 && senderPrefix != null) {
final senderMatches = filtered.where((n) => n.publicKey.startsWith(senderPrefix)).toList();
if (senderMatches.isNotEmpty) filtered = senderMatches;
} else if (i == pathHashes.length - 1 && recipientPrefix != null) {
final recipientMatches = filtered
.where((n) => n.publicKey.startsWith(recipientPrefix))
final senderMatches = filtered
.where((n) => n.publicKey.startsWith(senderPrefix))
.toList();
if (recipientMatches.isNotEmpty) filtered = recipientMatches;
if (senderMatches.isNotEmpty) filtered = senderMatches;
}
filtered.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
@@ -366,7 +560,8 @@ class _MessageTraceSheetState extends State<MessageTraceSheet> {
}) {
if (relayCount <= 0 || sender == null || recipient == null) return const [];
final candidates = nodes.where((n) {
if (sender.publicKey == n.publicKey || recipient.publicKey == n.publicKey) {
if (sender.publicKey == n.publicKey ||
recipient.publicKey == n.publicKey) {
return false;
}
return true;
@@ -434,3 +629,23 @@ class _TraceResult {
required this.matchedPathNodes,
});
}
class _RouteDisplayEntry {
final MeshMapNode? node;
final String label;
final String? keyLabel;
const _RouteDisplayEntry({
required this.node,
required this.label,
required this.keyLabel,
});
factory _RouteDisplayEntry.fromNode(MeshMapNode node) {
return _RouteDisplayEntry(
node: node,
label: node.name,
keyLabel: node.publicKey.substring(0, math.min(12, node.publicKey.length)),
);
}
}

View File

@@ -0,0 +1,432 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../l10n/app_localizations.dart';
class MessagesComposer extends StatelessWidget {
final TextEditingController textController;
final FocusNode focusNode;
final TextInputFormatter messageByteLimiter;
final int messageByteCount;
final int maxMessageBytes;
final bool isRecording;
final bool isSendingVoice;
final bool voiceSupported;
final double bottomPadding;
final String destinationLabel;
final Widget destinationAvatar;
final VoidCallback onShowComposerActions;
final VoidCallback onShowRecipientSelector;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onSendMessage;
const MessagesComposer({
super.key,
required this.textController,
required this.focusNode,
required this.messageByteLimiter,
required this.messageByteCount,
required this.maxMessageBytes,
required this.isRecording,
required this.isSendingVoice,
required this.voiceSupported,
required this.bottomPadding,
required this.destinationLabel,
required this.destinationAvatar,
required this.onShowComposerActions,
required this.onShowRecipientSelector,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
required this.onSendMessage,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(color: Colors.transparent),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(10, 4, 10, bottomPadding),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(10, 10, 10, 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
_ComposerActionButton(
isRecording: isRecording,
onPressed: isRecording
? onStopAndSendVoice
: onShowComposerActions,
),
const SizedBox(width: 8),
Expanded(
child: _DestinationSelector(
destinationLabel: destinationLabel,
destinationAvatar: destinationAvatar,
onTap: onShowRecipientSelector,
),
),
],
),
const SizedBox(height: 8),
ListenableBuilder(
listenable: Listenable.merge([
textController,
focusNode,
]),
builder: (context, _) {
final canSendText =
!isRecording &&
!isSendingVoice &&
textController.text.trim().isNotEmpty;
final semanticsLabel = isRecording
? 'Recording... release to send voice'
: (isSendingVoice
? 'Sending voice...'
: voiceSupported
? 'Send (long press to record voice)'
: 'Send');
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _MessageInput(
textController: textController,
focusNode: focusNode,
messageByteLimiter: messageByteLimiter,
),
),
const SizedBox(width: 8),
_SendButton(
canSendText: canSendText,
isRecording: isRecording,
isSendingVoice: isSendingVoice,
voiceSupported: voiceSupported,
semanticsLabel: semanticsLabel,
messageByteCount: messageByteCount,
maxMessageBytes: maxMessageBytes,
onSendMessage: onSendMessage,
onStartVoiceRecording: onStartVoiceRecording,
onStopAndSendVoice: onStopAndSendVoice,
),
],
);
},
),
],
),
),
),
),
),
],
),
);
}
}
class _ComposerActionButton extends StatelessWidget {
final bool isRecording;
final VoidCallback onPressed;
const _ComposerActionButton({
required this.isRecording,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: IconButton(
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 22),
tooltip: isRecording ? 'Stop recording' : 'More actions',
onPressed: onPressed,
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
),
);
}
}
class _DestinationSelector extends StatelessWidget {
final String destinationLabel;
final Widget destinationAvatar;
final VoidCallback onTap;
const _DestinationSelector({
required this.destinationLabel,
required this.destinationAvatar,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: Ink(
height: 42,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 10),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
Icon(
Icons.expand_more_rounded,
size: 20,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
),
),
);
}
}
class _MessageInput extends StatelessWidget {
final TextEditingController textController;
final FocusNode focusNode;
final TextInputFormatter messageByteLimiter;
const _MessageInput({
required this.textController,
required this.focusNode,
required this.messageByteLimiter,
});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
constraints: const BoxConstraints(minHeight: 46, maxHeight: 132),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: focusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor.withValues(alpha: 0.35),
width: focusNode.hasFocus ? 1.4 : 1,
),
boxShadow: focusNode.hasFocus
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.10),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
controller: textController,
focusNode: focusNode,
minLines: 1,
maxLines: 4,
keyboardType: TextInputType.multiline,
inputFormatters: [messageByteLimiter],
style: const TextStyle(fontSize: 15),
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
hintStyle: TextStyle(
fontSize: 15,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
),
filled: false,
fillColor: Colors.transparent,
border: InputBorder.none,
isCollapsed: true,
),
textInputAction: TextInputAction.newline,
),
),
);
}
}
class _SendButton extends StatelessWidget {
final bool canSendText;
final bool isRecording;
final bool isSendingVoice;
final bool voiceSupported;
final String semanticsLabel;
final int messageByteCount;
final int maxMessageBytes;
final Future<void> Function() onSendMessage;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
const _SendButton({
required this.canSendText,
required this.isRecording,
required this.isSendingVoice,
required this.voiceSupported,
required this.semanticsLabel,
required this.messageByteCount,
required this.maxMessageBytes,
required this.onSendMessage,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
enabled: canSendText || (voiceSupported && !isSendingVoice),
label: semanticsLabel,
onTap: canSendText ? onSendMessage : null,
onLongPress: (voiceSupported && !isSendingVoice)
? () {
if (isRecording) {
onStopAndSendVoice();
return;
}
onStartVoiceRecording();
}
: null,
child: Tooltip(
message: semanticsLabel,
excludeFromSemantics: true,
child: GestureDetector(
excludeFromSemantics: true,
onTap: canSendText ? onSendMessage : null,
onLongPressStart: (voiceSupported && !isSendingVoice)
? (_) => onStartVoiceRecording()
: null,
onLongPressEnd: (voiceSupported && isRecording)
? (_) => onStopAndSendVoice()
: null,
onLongPressCancel: (voiceSupported && isRecording)
? onStopAndSendVoice
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 46,
height: 46,
decoration: BoxDecoration(
color: canSendText || isRecording
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: canSendText || isRecording
? Colors.transparent
: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: canSendText || isRecording
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.22),
blurRadius: 14,
offset: const Offset(0, 6),
),
]
: null,
),
child: isSendingVoice
? Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
),
)
: Icon(
isRecording ? Icons.mic_rounded : Icons.send_rounded,
size: 22,
color: canSendText || isRecording
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'$messageByteCount/$maxMessageBytes',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: messageByteCount > maxMessageBytes * 0.9
? Colors.orange.shade800
: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/message.dart';
import '../../widgets/messages/message_bubble.dart';
class MessagesContent extends StatelessWidget {
static const double defaultPadding = 8;
final List<Message> messages;
final ScrollController scrollController;
final String? highlightedMessageId;
final double bottomContentPadding;
final Future<void> Function() onRefresh;
final VoidCallback? onNavigateToMap;
final ValueChanged<Message>? onMessageTap;
const MessagesContent({
super.key,
required this.messages,
required this.scrollController,
required this.highlightedMessageId,
this.bottomContentPadding = 0,
required this.onRefresh,
this.onNavigateToMap,
this.onMessageTap,
});
@override
Widget build(BuildContext context) {
return RefreshIndicator(
onRefresh: onRefresh,
child: messages.isEmpty
? LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
physics: const AlwaysScrollableScrollPhysics(),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.message_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context)!.noMessagesYet,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(context)!.pullDownToSync,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
)
: ListView.builder(
controller: scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
reverse: true,
padding: EdgeInsets.fromLTRB(
defaultPadding,
defaultPadding,
defaultPadding,
defaultPadding + bottomContentPadding,
),
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
return MessageBubble(
key: ValueKey(message.id),
message: message,
isHighlighted: message.id == highlightedMessageId,
onNavigateToMap: onNavigateToMap,
onTap: onMessageTap == null
? null
: () => onMessageTap!(message),
);
},
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../l10n/app_localizations.dart';
import '../common/contact_avatar.dart';
/// Bottom sheet for selecting message recipient (channel, contact, or room)
class RecipientSelectorSheet extends StatefulWidget {
@@ -168,7 +169,7 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
...filteredChannels.map((channel) {
return _buildRecipientTile(
context: context,
icon: Icons.public,
contact: channel,
title: channel.getLocalizedDisplayName(context),
subtitle: channel.isPublicChannel
? l10n.broadcastToAllNearby
@@ -215,10 +216,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
...filteredContacts.map((contact) {
return _buildRecipientTile(
context: context,
icon: Icons.person,
contact: contact,
title: contact.displayName,
subtitle: contact.publicKeyShort,
emoji: contact.roleEmoji,
isSelected: _isSelected('contact', contact),
onTap: () {
widget.onSelect('contact', contact);
@@ -261,10 +261,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
...filteredRooms.map((room) {
return _buildRecipientTile(
context: context,
icon: Icons.meeting_room,
contact: room,
title: room.displayName,
subtitle: room.publicKeyShort,
emoji: room.roleEmoji,
isSelected: _isSelected('room', room),
onTap: () {
widget.onSelect('room', room);
@@ -275,7 +274,9 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
],
// Empty state
if (widget.contacts.isEmpty && widget.rooms.isEmpty && widget.channels.isEmpty) ...[
if (widget.contacts.isEmpty &&
widget.rooms.isEmpty &&
widget.channels.isEmpty) ...[
Padding(
padding: const EdgeInsets.all(32),
child: Column(
@@ -310,36 +311,16 @@ class _RecipientSelectorSheetState extends State<RecipientSelectorSheet> {
Widget _buildRecipientTile({
required BuildContext context,
required IconData icon,
required Contact contact,
required String title,
required String subtitle,
String? emoji,
required bool isSelected,
required VoidCallback onTap,
}) {
return ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Icon(
icon,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
leading: ContactAvatar(contact: contact, radius: 20, displayName: title),
title: Row(
children: [
if (emoji != null && emoji.isNotEmpty) ...[
Text(emoji, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import '../../models/message.dart';
import '../../utils/message_extensions.dart';
class SystemMessageBubble extends StatelessWidget {
final Message message;
const SystemMessageBubble({super.key, required this.message});
Color _getLevelColor(String? level) {
switch (level?.toLowerCase()) {
case 'success':
return Colors.green;
case 'warning':
return Colors.orange;
case 'error':
return Colors.red;
case 'info':
default:
return Colors.blue.shade300;
}
}
IconData _getLevelIcon(String? level) {
switch (level?.toLowerCase()) {
case 'success':
return Icons.check_circle_outline;
case 'warning':
return Icons.warning_amber_outlined;
case 'error':
return Icons.error_outline;
case 'info':
default:
return Icons.info_outline;
}
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final level = message.senderName ?? 'info';
final levelColor = _getLevelColor(level);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: levelColor.withValues(alpha: isDarkMode ? 0.18 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: levelColor.withValues(alpha: isDarkMode ? 0.3 : 0.16),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(_getLevelIcon(level), size: 16, color: levelColor),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
level.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: levelColor,
fontWeight: FontWeight.bold,
letterSpacing: 0.4,
),
),
),
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
],
),
const SizedBox(height: 4),
Text(
message.text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
height: 1.3,
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.8),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -97,9 +97,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
children: [
Text(
'Tic-Tac-Toe · Game ${state.gameId}',
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
color: titleColor,
),
@@ -167,7 +165,7 @@ class TicTacToeMessageBubble extends StatelessWidget {
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: opponent.publicKey,
);
messagesProvider.addSentMessage(sentMessage);
messagesProvider.addSentMessage(sentMessage, contact: opponent);
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: opponent.publicKey,

View File

@@ -2,9 +2,12 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../providers/voice_provider.dart';
import '../../utils/transmission_target_resolver.dart';
import '../../utils/voice_message_parser.dart';
@@ -27,7 +30,9 @@ class VoiceMessageBubble extends StatefulWidget {
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
static const int _maxFetchHops = 3;
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
bool _isRequesting = false;
bool _isPartialRequest = false;
bool _autoPlayWhenReady = false;
String? _errorText;
Timer? _requestTimeoutTimer;
@@ -54,6 +59,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return Consumer<VoiceProvider>(
builder: (context, voiceProvider, _) {
final transferCount = context.select<MessagesProvider, int>(
(provider) =>
provider.transferCountForSession(voiceSessionId: voiceId),
);
final contactsProvider = context.read<ContactsProvider>();
final session = voiceProvider.session(voiceId);
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
@@ -62,11 +71,10 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope?.senderKey6,
senderName: widget.message.senderName,
);
final effectivePathLen = sender != null && sender.outPathLen >= 0
? sender.outPathLen
final effectivePathLen = sender != null && sender.routeHasPath
? sender.routeHopCount
: widget.message.pathLen;
final isPlaying = voiceProvider.isPlaying(voiceId);
final isComplete = voiceProvider.isComplete(voiceId);
@@ -76,6 +84,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
if (!mounted) return;
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = null;
});
});
@@ -92,9 +101,17 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
final received = session?.receivedCount ?? 0;
final total = session?.total ?? envelope?.total ?? 0;
final playbackProgress = voiceProvider.playbackProgress(voiceId);
final requestProgress = total > 0
? (received / total).clamp(0.0, 1.0)
: null;
final packetPresence =
session?.packets.map((packet) => packet != null).toList() ??
List<bool>.filled(total, false);
final isReceivingData =
!_isRequesting &&
!isComplete &&
_hasRecentInboundActivity(
lastReceivedAt: session?.lastPacketAt,
received: received,
total: total,
);
final durationSec =
session?.estimatedDurationSeconds ??
((envelope?.durationMs ?? 0) / 1000.0);
@@ -116,32 +133,37 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
final eta = voiceProvider.estimateRemainingTransferTime(voiceId);
Future<void> handlePrimaryTap() async {
if (isPlaying) {
await voiceProvider.stop();
return;
}
if (_isRequesting) {
_cancelReceive(voiceId);
return;
}
if (isComplete) {
await voiceProvider.play(voiceId);
return;
}
if (isReceivingData) {
return;
}
await _requestAndPlayVoice(
voiceId,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: effectivePathLen,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () async {
if (isPlaying) {
await voiceProvider.stop();
return;
}
if (_isRequesting) {
_cancelReceive(voiceId);
return;
}
if (isComplete) {
await voiceProvider.play(voiceId);
return;
}
await _requestAndPlayVoice(
voiceId,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: effectivePathLen,
);
},
onTap: handlePrimaryTap,
borderRadius: BorderRadius.circular(24),
child: Container(
width: 48,
@@ -155,11 +177,16 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
child: Icon(
isPlaying
? Icons.stop
: (_isRequesting ? Icons.close : Icons.play_arrow),
: (_isRequesting
? Icons.close
: (isReceivingData
? Icons.downloading_rounded
: Icons.play_arrow)),
size: 28,
color: widget.isSentByMe
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer,
: Theme.of(context).colorScheme.onSecondaryContainer
.withValues(alpha: isReceivingData ? 0.6 : 1.0),
),
),
),
@@ -168,14 +195,22 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (isPlaying || _isRequesting)
if (isPlaying)
SizedBox(
width: 100,
child: LinearProgressIndicator(
value: isPlaying ? playbackProgress : requestProgress,
value: playbackProgress,
backgroundColor: Colors.grey.withValues(alpha: 0.3),
),
)
else if ((_isRequesting || isReceivingData) && total > 0)
_PacketBlockProgress(
presence: packetPresence,
activeColor: widget.isSentByMe
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.secondary,
highlightMissing: _isPartialRequest,
)
else
_WaveformBar(isComplete: isComplete, bars: waveformBars),
const SizedBox(height: 4),
@@ -188,11 +223,15 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
total: total,
isComplete: isComplete,
isRequesting: _isRequesting,
isReceivingData: isReceivingData,
isPartialRequest: _isPartialRequest,
errorText: _errorText,
requestingLabel: AppLocalizations.of(
context,
)!.requestingVoice,
eta: eta,
isSentByMe: widget.isSentByMe,
transferCount: transferCount,
),
style: TextStyle(
fontSize: 11,
@@ -218,23 +257,31 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
int pathLen = 0,
}) async {
if (_isRequesting) return;
setState(() {
_isRequesting = true;
_isPartialRequest = false;
_autoPlayWhenReady = true;
_errorText = null;
});
final connectionProvider = context.read<ConnectionProvider>();
final voiceProvider = context.read<VoiceProvider>();
voiceProvider.resumeIncomingSession(sessionId);
final contactsProvider = context.read<ContactsProvider>();
final resolution = await TransmissionTargetResolver.resolveFetchTarget(
final appProvider = context.read<AppProvider>();
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: connectionProvider.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderKey6FromEnvelope: envelope?.senderKey6,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.',
@@ -242,6 +289,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.',
@@ -249,27 +297,93 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unreachable) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route did not respond to a path check. Sync contacts/path and try again.',
);
return;
}
final sender = resolution.target!;
if (sender.outPathLen >= 2) {
var sender = resolution.target!;
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
await connectionProvider.getContacts();
if (!mounted) return;
resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: connectionProvider.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender contact is unknown. Sync contacts first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route is unknown. Sync contacts/path first.',
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Message is too far (${resolution.hops} hops, max ${resolution.maxHops}).',
);
return;
}
sender = resolution.target!;
routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route did not respond on the raw transport path.',
);
return;
}
}
if (!sender.routeSupportsLegacyRawTransport) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Sender route uses 3-byte hashes. Raw media fetch is not supported in this client yet.',
);
return;
}
if (sender.routeHopCount >= 2) {
_showToast(
'Voice fetch over ${sender.outPathLen} hops may take a while.',
'Voice fetch over ${sender.routeHopCount} hops may take a while.',
);
}
if (!mounted) return;
setState(() {
_errorText = null;
});
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert(
'Cannot fetch voice',
'Device key is unavailable.',
@@ -281,23 +395,38 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final request = VoiceFetchRequest(
final missing = voiceProvider.missingPacketIndices(sessionId);
final totalPackets = sessionPacketCount(
voiceProvider: voiceProvider,
sessionId: sessionId,
requesterKey6: requesterKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
version: 2,
envelope: envelope,
);
setState(() {
_isRequesting = true;
_autoPlayWhenReady = true;
_errorText = null;
});
final isPartialResume =
missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets;
if (_isPartialRequest != isPartialResume && mounted) {
setState(() {
_isPartialRequest = isPartialResume;
});
}
final request = isPartialResume
? VoiceFetchRequest(
sessionId: sessionId,
want: 'missing',
missingIndices: missing,
requesterKey6: requesterKey6,
)
: VoiceFetchRequest(
sessionId: sessionId,
requesterKey6: requesterKey6,
);
try {
debugPrint(
'🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
);
await connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.outPathLen,
contactPathLen: sender.routeSignedPathLen,
payload: request.encodeBinary(),
);
} catch (_) {
@@ -306,14 +435,21 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
}
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.outPathLen >= 0
? sender.outPathLen
final effectivePathLen = sender.routeHasPath
? sender.routeHopCount
: pathLen;
final estimatedDurationMs =
envelope != null &&
totalPackets > 0 &&
missing.isNotEmpty &&
missing.length < totalPackets
? ((envelope.durationMs * missing.length) / totalPackets).round()
: envelope?.durationMs;
final txEstimate = envelope != null
? estimateVoiceTransmitDuration(
packetCount: envelope.total,
packetCount: isPartialResume ? missing.length : envelope.total,
mode: envelope.mode,
durationMs: envelope.durationMs,
durationMs: estimatedDurationMs ?? envelope.durationMs,
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
@@ -331,16 +467,34 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
);
}
int sessionPacketCount({
required VoiceProvider voiceProvider,
required String sessionId,
required VoiceEnvelope? envelope,
}) {
return voiceProvider.session(sessionId)?.total ?? envelope?.total ?? 0;
}
void _setUnavailable() {
if (!mounted) return;
_showToast(AppLocalizations.of(context)!.voiceUnavailable);
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_autoPlayWhenReady = false;
_errorText = AppLocalizations.of(context)!.voiceUnavailable;
});
}
void _clearRequestState() {
if (!mounted) return;
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_autoPlayWhenReady = false;
});
}
void _cancelReceive(String sessionId) {
if (!mounted) return;
_requestTimeoutTimer?.cancel();
@@ -348,6 +502,7 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
_showToast('Voice receive canceled');
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_autoPlayWhenReady = false;
_errorText = 'Voice receive canceled';
});
@@ -392,19 +547,33 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
required int total,
required bool isComplete,
required bool isRequesting,
required bool isReceivingData,
required bool isPartialRequest,
required String? errorText,
required String requestingLabel,
required Duration? eta,
required bool isSentByMe,
required int transferCount,
}) {
if (errorText != null) return errorText;
final progress = total > 0 ? ' ($received/$total)' : '';
if (isRequesting) {
return '$requestingLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
final actionLabel = isPartialRequest
? 'Fetching missing voice fragments'
: requestingLabel;
return '$actionLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
}
if (isReceivingData) {
return 'Receiving voice$progress · ${_formatEta(eta)} · $txEstimateLabel';
}
if (!isComplete && total > 0) {
return '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
return isSentByMe
? '🎙️ $durationLabel · $modeLabel$progress · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
}
return '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
return isSentByMe
? '🎙️ $durationLabel · $modeLabel · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
}
List<double> _resolveWaveformBars({
@@ -487,6 +656,86 @@ class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
final seconds = eta.inSeconds % 60;
return 'ETA ~${minutes}m ${seconds}s';
}
static String _formatTransferCount(int transferCount) {
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
}
bool _hasRecentInboundActivity({
required DateTime? lastReceivedAt,
required int received,
required int total,
}) {
if (lastReceivedAt == null || received <= 0 || received >= total) {
return false;
}
return DateTime.now().difference(lastReceivedAt) <=
_recentInboundActivityWindow;
}
}
class _PacketBlockProgress extends StatelessWidget {
final List<bool> presence;
final Color activeColor;
final bool highlightMissing;
const _PacketBlockProgress({
required this.presence,
required this.activeColor,
this.highlightMissing = false,
});
@override
Widget build(BuildContext context) {
if (presence.isEmpty) {
return const SizedBox(width: 100, height: 16);
}
final bucketCount = presence.length <= 20 ? presence.length : 20;
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
final start = (bucketIndex * presence.length) ~/ bucketCount;
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
final safeEnd = end <= start ? start + 1 : end;
final slice = presence.sublist(start, safeEnd);
final received = slice.where((value) => value).length;
return slice.isEmpty ? 0.0 : received / slice.length;
});
final missingColor = highlightMissing
? Colors.amberAccent
: Colors.white.withValues(alpha: 0.14);
return SizedBox(
width: 100,
height: 16,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final fill in bucketFill)
Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: fill > 0
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
: missingColor.withValues(
alpha: highlightMissing ? 0.45 : 0.14,
),
borderRadius: BorderRadius.circular(2),
border: Border.all(
color: fill > 0
? Colors.white.withValues(alpha: 0.18)
: missingColor.withValues(
alpha: highlightMissing ? 0.7 : 0.18,
),
width: 0.5,
),
),
),
),
],
),
);
}
}
/// Voice waveform rendered as a row of bars.

View File

@@ -9,7 +9,6 @@
#include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_avif_linux/flutter_avif_linux_plugin.h>
#include <objectbox_flutter_libs/objectbox_flutter_libs_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
@@ -23,9 +22,6 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_avif_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAvifLinuxPlugin");
flutter_avif_linux_plugin_register_with_registrar(flutter_avif_linux_registrar);
g_autoptr(FlPluginRegistrar) objectbox_flutter_libs_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "ObjectboxFlutterLibsPlugin");
objectbox_flutter_libs_plugin_register_with_registrar(objectbox_flutter_libs_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);

View File

@@ -6,7 +6,6 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux
file_selector_linux
flutter_avif_linux
objectbox_flutter_libs
record_linux
url_launcher_linux
)

View File

@@ -14,8 +14,8 @@ import flutter_blue_plus_darwin
import flutter_local_notifications
import geolocator_apple
import nsd_macos
import objectbox_flutter_libs
import package_info_plus
import path_provider_foundation
import record_macos
import share_plus
import shared_preferences_foundation
@@ -32,8 +32,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin"))
ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

View File

@@ -20,6 +20,8 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>

View File

@@ -29,10 +29,10 @@ packages:
dependency: "direct main"
description:
name: audioplayers
sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4"
sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70
url: "https://pub.dev"
source: hosted
version: "6.5.1"
version: "6.6.0"
audioplayers_android:
dependency: transitive
description:
@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: audioplayers_darwin
sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333"
sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91
url: "https://pub.dev"
source: hosted
version: "6.3.0"
version: "6.4.0"
audioplayers_linux:
dependency: transitive
description:
@@ -69,18 +69,18 @@ packages:
dependency: transitive
description:
name: audioplayers_web
sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7"
sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9
url: "https://pub.dev"
source: hosted
version: "5.1.1"
version: "5.2.0"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7"
sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734
url: "https://pub.dev"
source: hosted
version: "4.2.1"
version: "4.3.0"
bluez:
dependency: transitive
description:
@@ -129,14 +129,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
codec2_flutter:
dependency: "direct main"
description:
@@ -226,14 +218,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.3"
executor_lib:
dependency: transitive
description:
name: executor_lib
sha256: "95ddf2957d9942d9702855b38dd49677f0ee6a8b77d7b16c0e509c7669d17386"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
exif:
dependency: transitive
description:
@@ -243,7 +227,7 @@ packages:
source: hosted
version: "3.3.0"
fake_async:
dependency: transitive
dependency: "direct dev"
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
@@ -314,14 +298,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flat_buffers:
dependency: transitive
description:
name: flat_buffers
sha256: "380bdcba5664a718bfd4ea20a45d39e13684f5318fcd8883066a55e21f37f4c3"
url: "https://pub.dev"
source: hosted
version: "23.5.26"
flutter:
dependency: "direct main"
description: flutter
@@ -515,34 +491,34 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
sha256: "0d9035862236fe38250fe1644d7ed3b8254e34a21b2c837c9f539fbb3bba5ef1"
url: "https://pub.dev"
source: hosted
version: "20.1.0"
version: "21.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
sha256: e0f25e243c6c44c825bbbc6b2b2e76f7d9222362adcfe9fd780bf01923c840bd
url: "https://pub.dev"
source: hosted
version: "7.0.0"
version: "8.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
sha256: e7db3d5b49c2b7ecc68deba4aaaa67a348f92ee0fef34c8e4b4459dbef0d7307
url: "https://pub.dev"
source: hosted
version: "10.0.0"
version: "11.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
sha256: "3a2654ba104fbb52c618ebed9def24ef270228470718c43b3a6afcd5c81bef0c"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
version: "3.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
@@ -551,19 +527,12 @@ packages:
flutter_map:
dependency: "direct main"
description:
name: flutter_map
sha256: "391e7dc95cc3f5190748210a69d4cfeb5d8f84dcdfa9c3235d0a9d7742ccb3f8"
url: "https://pub.dev"
source: hosted
path: "."
ref: master
resolved-ref: fdc089aeb4fad05a4f2314f75bbd66c9b7a70668
url: "https://github.com/fleaflet/flutter_map.git"
source: git
version: "8.2.2"
flutter_map_tile_caching:
dependency: "direct main"
description:
name: flutter_map_tile_caching
sha256: "90e097223d8ab74425cf15b449a03adfa4d4c28406dc757e1c396aff0f9beba7"
url: "https://pub.dev"
source: hosted
version: "10.1.1"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -646,14 +615,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.5"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
gsettings:
dependency: transitive
description:
@@ -662,14 +623,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.8"
hooks:
dependency: transitive
description:
name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
http:
dependency: "direct main"
description:
@@ -694,14 +647,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
idb_shim:
dependency: transitive
description:
name: idb_shim
sha256: "921301da0a735f336a28fc35c3abdbd4498895cc205fa1ea9f7e785e7d854ceb"
url: "https://pub.dev"
source: hosted
version: "2.8.2+4"
image:
dependency: transitive
description:
@@ -830,38 +775,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
lists:
dependency: transitive
description:
name: lists
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
logger:
dependency: transitive
description:
name: logger
sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3
url: "https://pub.dev"
source: hosted
version: "2.6.2"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.18"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
@@ -870,20 +791,12 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.13.0"
mbtiles:
dependency: "direct main"
description:
name: mbtiles
sha256: "316af1f8db8ce95888ca70f5dd3f6914906b4e17ceeca8501206d28e78612af8"
url: "https://pub.dev"
source: hosted
version: "0.4.2"
meshcore_client:
dependency: "direct main"
description:
path: "."
ref: main
resolved-ref: "11f51ccaba850531496179bf63e023cb4c9ad797"
resolved-ref: cea66b5251135c7f9b84f15c0878d4c8af6e88e9
url: "https://github.com/dz0ny/meshcore_client.git"
source: git
version: "0.1.0"
@@ -899,10 +812,10 @@ packages:
dependency: transitive
description:
name: mgrs_dart
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
version: "3.0.0"
mime:
dependency: transitive
description:
@@ -911,14 +824,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev"
source: hosted
version: "0.17.4"
nested:
dependency: transitive
description:
@@ -975,30 +880,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.1"
objectbox:
dependency: transitive
description:
name: objectbox
sha256: "3cc186749178a3556e1020c9082d0897d0f9ecbdefcc27320e65c5bc650f0e57"
url: "https://pub.dev"
source: hosted
version: "4.3.1"
objectbox_flutter_libs:
dependency: transitive
description:
name: objectbox_flutter_libs
sha256: cd754766e04229a4f51250f121813d9a3c1a74fc21cd68e48b3c6085cbcd6c85
url: "https://pub.dev"
source: hosted
version: "4.3.1"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_info_plus:
dependency: "direct main"
description:
@@ -1040,13 +921,13 @@ packages:
source: hosted
version: "2.2.22"
path_provider_foundation:
dependency: transitive
dependency: "direct overridden"
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
version: "2.5.1"
path_provider_linux:
dependency: transitive
description:
@@ -1163,10 +1044,10 @@ packages:
dependency: "direct main"
description:
name: proj4dart
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859
url: "https://pub.dev"
source: hosted
version: "2.1.0"
version: "3.0.0"
protobuf:
dependency: transitive
description:
@@ -1183,14 +1064,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
record:
dependency: "direct main"
description:
@@ -1263,14 +1136,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
sembast:
dependency: transitive
description:
name: sembast
sha256: "139cf71496105de32e7a08a4e3a1ead0f81c4a616ec9703ed07e8f0d10cdd505"
url: "https://pub.dev"
source: hosted
version: "3.8.6"
share_plus:
dependency: "direct main"
description:
@@ -1359,6 +1224,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.4"
simple_sparse_list:
dependency: transitive
description:
name: simple_sparse_list
sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f
url: "https://pub.dev"
source: hosted
version: "0.1.4"
sky_engine:
dependency: transitive
description: flutter
@@ -1420,14 +1293,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
url: "https://pub.dev"
source: hosted
version: "2.9.4"
stack_trace:
dependency: transitive
description:
@@ -1472,18 +1337,18 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.9"
version: "0.7.10"
timezone:
dependency: "direct main"
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
sha256: "784a5e34d2eb62e1326f24d6f600aaaee452eb8ca8ef2f384a59244e292d158b"
url: "https://pub.dev"
source: hosted
version: "0.10.1"
version: "0.11.0"
typed_data:
dependency: transitive
description:
@@ -1496,10 +1361,10 @@ packages:
dependency: transitive
description:
name: unicode
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
url: "https://pub.dev"
source: hosted
version: "0.3.1"
version: "1.1.9"
url_launcher:
dependency: "direct main"
description:
@@ -1572,23 +1437,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.5.3"
vector_map_tiles:
dependency: "direct main"
description:
name: vector_map_tiles
sha256: e35f090c428f05e44dd525fa4fedaafd1dbcd28b656cb0ea908528c6ce84a87d
url: "https://pub.dev"
source: hosted
version: "9.0.0-beta.8"
vector_map_tiles_mbtiles:
dependency: "direct main"
description:
path: vector_map_tiles_mbtiles
ref: HEAD
resolved-ref: a09543b7590b373f3ac53f4776e343fee41c7dc6
url: "https://github.com/josxha/flutter_map_plugins.git"
source: git
version: "1.2.1"
vector_math:
dependency: transitive
description:
@@ -1597,30 +1445,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vector_tile:
dependency: transitive
description:
name: vector_tile
sha256: "7ae290246e3a8734422672dbe791d3f7b8ab631734489fc6d405f1cc2080e38c"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
vector_tile_dem:
dependency: transitive
description:
name: vector_tile_dem
sha256: "81a3568d2213817bd2698f919357e5107c0261491ae1014e821ed4fc3c2bf740"
url: "https://pub.dev"
source: hosted
version: "0.0.2"
vector_tile_renderer:
dependency: "direct main"
description:
name: vector_tile_renderer
sha256: "99530edb073c1cea3c6a4bdb5ca9a5c6779c25ddf1a645678458504708dc221c"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
vibration:
dependency: "direct main"
description:
@@ -1702,5 +1526,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.1"

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 2026.0305.3+9
version: 2026.0308.1+21
environment:
sdk: ^3.9.2
@@ -66,24 +66,16 @@ dependencies:
provider: ^6.1.0
# Map display
flutter_map: ^8.2.2
flutter_map:
git:
url: https://github.com/fleaflet/flutter_map.git
ref: master
latlong2: ^0.9.0
# Offline tile caching
flutter_map_tile_caching: ^10.1.1
# Vector map tiles
vector_map_tiles: ^9.0.0-beta.8
vector_map_tiles_mbtiles:
git:
url: https://github.com/josxha/flutter_map_plugins.git
path: vector_map_tiles_mbtiles
vector_tile_renderer: ^6.0.0
mbtiles: ^0.4.0
http: ^1.2.0
# Coordinate system projections for WMS (EPSG:3794)
proj4dart: ^2.1.0
proj4dart: ^3.0.0
# Permissions
permission_handler: ^12.0.1
@@ -111,8 +103,8 @@ dependencies:
flutter_background_service: ^5.0.13
# Notifications
flutter_local_notifications: ^20.1.0
timezone: ^0.10.0
flutter_local_notifications: ^21.0.0
timezone: ^0.11.0
# Vibration
vibration: ^3.1.4
@@ -144,6 +136,13 @@ dev_dependencies:
# rules and activating additional ones.
flutter_lints: ^6.0.0
flutter_launcher_icons: "^0.14.4"
fake_async: ^1.3.3
dependency_overrides:
# path_provider_foundation 2.6.0 pulls in package:objective_c as a native
# asset. That framework has been ending up archived with a macOS platform
# slice and fails App Store validation for iOS uploads.
path_provider_foundation: 2.5.1
flutter_launcher_icons:
android: "launcher_icon"

View File

@@ -0,0 +1,117 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/contact.dart';
void main() {
Contact buildContact({
required int signedPathLen,
required Uint8List outPath,
}) {
return Contact(
publicKey: Uint8List.fromList(List<int>.generate(32, (index) => index)),
type: ContactType.chat,
flags: 0,
outPathLen: signedPathLen,
outPath: outPath,
advName: 'Route Contact',
lastAdvert: 0,
advLat: 0,
advLon: 0,
lastMod: 0,
);
}
group('ContactRouteCodec.parse', () {
test('parses 1-byte hop routes', () {
final route = ContactRouteCodec.parse('AA,BB,CC');
expect(route.hashSize, 1);
expect(route.hopCount, 3);
expect(route.encodedPathLen, 0x03);
expect(route.canonicalText, 'AA,BB,CC');
expect(route.pathBytes, [0xAA, 0xBB, 0xCC]);
});
test('parses 2-byte hop routes', () {
final route = ContactRouteCodec.parse('AABB,CCDD');
expect(route.hashSize, 2);
expect(route.hopCount, 2);
expect(route.encodedPathLen, 0x42);
expect(route.canonicalText, 'AABB,CCDD');
expect(route.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD]);
});
test('parses 3-byte hop routes', () {
final route = ContactRouteCodec.parse('AABBCC,DDEEFF');
expect(route.hashSize, 3);
expect(route.hopCount, 2);
expect(route.encodedPathLen, 0x82);
expect(route.signedEncodedPathLen, -126);
expect(route.pathBytes, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
});
test('accepts colon-separated hops and normalizes output', () {
final route = ContactRouteCodec.parse('AA:BB,CC:DD');
expect(route.hashSize, 2);
expect(route.canonicalText, 'AABB,CCDD');
});
test('rejects mixed hop widths', () {
expect(
() => ContactRouteCodec.parse('AA,AABB'),
throwsA(isA<ContactRouteFormatException>()),
);
});
test('rejects routes that do not match the configured hash size', () {
expect(
() => ContactRouteCodec.parse('AABB,CCDD', expectedHashSize: 1),
throwsA(isA<ContactRouteFormatException>()),
);
});
test('rejects invalid tokens', () {
expect(
() => ContactRouteCodec.parse('AA,XYZ'),
throwsA(isA<ContactRouteFormatException>()),
);
expect(
() => ContactRouteCodec.parse('AAA'),
throwsA(isA<ContactRouteFormatException>()),
);
});
test('rejects routes over 64 bytes', () {
final tooLong = List.filled(22, 'AABBCC').join(',');
expect(
() => ContactRouteCodec.parse(tooLong),
throwsA(isA<ContactRouteFormatException>()),
);
});
});
group('Contact route helpers', () {
test('interprets signed 3-byte descriptors as valid routes', () {
final outPath = Uint8List(64)
..setRange(0, 6, [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
final contact = buildContact(signedPathLen: -126, outPath: outPath);
expect(contact.routeHasPath, isTrue);
expect(contact.routeHashSize, 3);
expect(contact.routeHopCount, 2);
expect(contact.routeCanonicalText, 'AABBCC,DDEEFF');
expect(contact.routeSupportsLegacyRawTransport, isFalse);
});
test('treats -1 as unknown route', () {
final contact = buildContact(signedPathLen: -1, outPath: Uint8List(0));
expect(contact.routeHasPath, isFalse);
expect(contact.routeSummary, 'Flood/Unknown');
});
});
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/models/message_reception_details.dart';
void main() {
test('drops impossible transmit estimate for received messages', () {
expect(
sanitizeEstimatedTransmitMs(
estimatedTransmitMs: 16 * 60 * 1000 + 54 * 1000,
senderToReceiptMs: 4200,
),
isNull,
);
});
test(
'keeps close transmit estimate despite second-level timestamp rounding',
() {
expect(
sanitizeEstimatedTransmitMs(
estimatedTransmitMs: 1800,
senderToReceiptMs: 900,
),
1800,
);
},
);
test('round trips reception details json', () {
final details = MessageReceptionDetails(
capturedAt: DateTime.fromMillisecondsSinceEpoch(1700000000000),
packetLoggedAt: DateTime.fromMillisecondsSinceEpoch(1700000000500),
rssiDbm: -92,
snrDb: 7.5,
pathBytes: const [0xAA, 0xBB, 0xCC],
senderToReceiptMs: 4200,
estimatedTransmitMs: 1800,
postTransmitDelayMs: 2400,
);
final decoded = MessageReceptionDetails.fromJson(details.toJson());
expect(decoded, isNotNull);
expect(decoded!.rssiDbm, -92);
expect(decoded.snrDb, 7.5);
expect(decoded.pathBytesHex, 'aa:bb:cc');
expect(decoded.senderToReceiptMs, 4200);
expect(decoded.estimatedTransmitMs, 1800);
expect(decoded.postTransmitDelayMs, 2400);
});
}

View File

@@ -1,9 +1,11 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';
import 'package:meshcore_sar_app/models/contact.dart';
import 'package:meshcore_sar_app/providers/contacts_provider.dart';
import 'package:meshcore_sar_app/services/cayenne_lpp_parser.dart';
import 'package:meshcore_sar_app/utils/fast_gps_packet.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
@@ -128,15 +130,18 @@ void main() {
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(45.0001, 0.0001));
expect(updated.displayLocation!.longitude, closeTo(13.9999, 0.0001));
expect(updated.advLat, equals((45.0001 * 1e6).round()));
expect(updated.advLon, equals((13.9999 * 1e6).round()));
});
test(
'retains last valid gps for chat/repeater/room when telemetry gps is invalid or missing',
'retains last valid gps for any contact when telemetry gps is invalid or missing',
() {
final contactTypes = <ContactType>[
ContactType.chat,
ContactType.repeater,
ContactType.room,
ContactType.channel,
];
for (var i = 0; i < contactTypes.length; i++) {
@@ -194,5 +199,338 @@ void main() {
}
},
);
test(
'retains last known gps when a contact refresh arrives without location',
() {
final firstFix = CayenneLppParser.createGpsData(
latitude: 45.1234,
longitude: 13.8765,
);
provider.updateTelemetry(publicKey.sublist(0, 6), firstFix);
provider.addOrUpdateContact(
Contact(
publicKey: publicKey,
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Test Contact',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNotNull);
expect(
updated.telemetry!.gpsLocation!.latitude,
closeTo(45.1234, 0.0001),
);
expect(
updated.telemetry!.gpsLocation!.longitude,
closeTo(13.8765, 0.0001),
);
expect(updated.advLat, equals((45.1234 * 1e6).round()));
expect(updated.advLon, equals((13.8765 * 1e6).round()));
expect(updated.displayLocation, isNotNull);
expect(updated.displayLocation!.latitude, closeTo(45.1234, 0.0001));
expect(updated.displayLocation!.longitude, closeTo(13.8765, 0.0001));
},
);
test('retains existing telemetry when contact refresh omits telemetry', () {
final initialTelemetry = ContactTelemetry(
gpsLocation: const LatLng(45.1234, 13.8765),
batteryPercentage: 76.5,
batteryMilliVolts: 3890,
temperature: 21.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 5)),
humidity: 62.0,
pressure: 1008.4,
extraSensorData: const {'co2': 415.0},
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
).copyWith(telemetry: initialTelemetry),
);
provider.addOrUpdateContact(
Contact(
publicKey: publicKey,
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Test Contact Refreshed',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765));
expect(updated.telemetry!.batteryPercentage, equals(76.5));
expect(updated.telemetry!.batteryMilliVolts, equals(3890));
expect(updated.telemetry!.temperature, equals(21.5));
expect(updated.telemetry!.humidity, equals(62.0));
expect(updated.telemetry!.pressure, equals(1008.4));
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
});
test('retains existing telemetry during bulk contacts sync', () {
final initialTelemetry = ContactTelemetry(
gpsLocation: const LatLng(45.1234, 13.8765),
batteryPercentage: 76.5,
batteryMilliVolts: 3890,
temperature: 21.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 5)),
humidity: 62.0,
pressure: 1008.4,
extraSensorData: const {'co2': 415.0},
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
).copyWith(telemetry: initialTelemetry),
);
provider.addContacts([
Contact(
publicKey: publicKey,
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: 'Synced Contact',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: 0,
advLon: 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
),
]);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, const LatLng(45.1234, 13.8765));
expect(updated.telemetry!.batteryPercentage, equals(76.5));
expect(updated.telemetry!.batteryMilliVolts, equals(3890));
expect(updated.telemetry!.temperature, equals(21.5));
expect(updated.telemetry!.humidity, equals(62.0));
expect(updated.telemetry!.pressure, equals(1008.4));
expect(updated.telemetry!.extraSensorData, containsPair('co2', 415.0));
});
test('retains prior telemetry fields across sparse telemetry updates', () {
final fullTelemetry = ContactTelemetry(
gpsLocation: const LatLng(46.0569, 14.5058),
batteryPercentage: 54.0,
batteryMilliVolts: 3780,
temperature: 19.5,
timestamp: DateTime.now().subtract(const Duration(minutes: 2)),
humidity: 58.0,
pressure: 1011.2,
extraSensorData: const {'pm25': 8.0},
);
provider.addOrUpdateContact(
createContact(
key: publicKey,
type: ContactType.chat,
).copyWith(telemetry: fullTelemetry),
);
final batteryOnly = CayenneLppParser.createBatteryData(3.95);
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, const LatLng(46.0569, 14.5058));
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
expect(updated.telemetry!.batteryPercentage, isNotNull);
expect(updated.telemetry!.temperature, equals(19.5));
expect(updated.telemetry!.humidity, equals(58.0));
expect(updated.telemetry!.pressure, equals(1011.2));
expect(updated.telemetry!.extraSensorData, containsPair('pm25', 8.0));
});
test('builds message snapshot from latest valid telemetry', () {
final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001,
longitude: 13.9999,
);
provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData);
final contact = provider.findContactByKey(publicKey)!;
final snapshot = provider.buildMessageContactLocationSnapshot(
contact,
capturedAt: DateTime.now(),
);
expect(snapshot, isNotNull);
expect(snapshot!.source, equals('telemetry'));
expect(snapshot.location.latitude, closeTo(45.0001, 0.0001));
expect(snapshot.location.longitude, closeTo(13.9999, 0.0001));
});
test('builds message snapshot from advert when telemetry is invalid', () {
final invalidTelemetry = CayenneLppParser.createGpsData(
latitude: 0.0,
longitude: 0.0,
);
provider.updateTelemetry(publicKey.sublist(0, 6), invalidTelemetry);
final contact = provider.findContactByKey(publicKey)!;
final snapshot = provider.buildMessageContactLocationSnapshot(
contact,
capturedAt: DateTime.now(),
);
expect(snapshot, isNotNull);
expect(snapshot!.source, equals('advert'));
expect(snapshot.location.latitude, closeTo(46.0569, 0.000001));
expect(snapshot.location.longitude, closeTo(14.5058, 0.000001));
});
test(
'persists last valid telemetry gps on the contact across reloads',
() async {
final telemetryData = CayenneLppParser.createGpsData(
latitude: 45.0001,
longitude: 13.9999,
);
provider.updateTelemetry(publicKey.sublist(0, 6), telemetryData);
await Future<void>.delayed(Duration.zero);
final reloadedProvider = ContactsProvider();
await reloadedProvider.initializeEarly();
final reloaded = reloadedProvider.findContactByKey(publicKey)!;
expect(reloaded.advLat, equals((45.0001 * 1e6).round()));
expect(reloaded.advLon, equals((13.9999 * 1e6).round()));
expect(reloaded.advertLocation, isNotNull);
expect(reloaded.advertLocation!.latitude, closeTo(45.0001, 0.0001));
expect(reloaded.advertLocation!.longitude, closeTo(13.9999, 0.0001));
},
);
});
group('ContactsProvider route updates', () {
late ContactsProvider provider;
late Uint8List publicKey;
setUp(() {
SharedPreferences.setMockInitialValues({});
provider = ContactsProvider();
publicKey = createPublicKey(64);
provider.addOrUpdateContact(
createContact(key: publicKey, type: ContactType.chat, name: 'Routey'),
);
});
test('optimistically stores a multi-byte route locally', () {
final route = ContactRouteCodec.parse('AABB,CCDD');
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.routeHasPath, isTrue);
expect(updated.routeHashSize, 2);
expect(updated.routeHopCount, 2);
expect(updated.routeCanonicalText, 'AABB,CCDD');
});
test('resetContactRouteLocal clears route state', () {
final route = ContactRouteCodec.parse('AA,BB,CC');
provider.setContactRouteLocal(
publicKey,
signedEncodedPathLen: route.signedEncodedPathLen,
paddedPathBytes: route.paddedPathBytes,
);
provider.resetContactRouteLocal(publicKey);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.routeHasPath, isFalse);
expect(updated.routeSummary, 'Flood/Unknown');
});
});
group('ContactsProvider.updateFastGps', () {
late ContactsProvider provider;
late Uint8List publicKey;
setUp(() {
SharedPreferences.setMockInitialValues({});
provider = ContactsProvider();
publicKey = createPublicKey(50);
provider.addOrUpdateContact(
createContact(key: publicKey, type: ContactType.chat, name: 'Fast GPS'),
);
});
test('updates gps while preserving other telemetry fields', () {
final batteryOnly = CayenneLppParser.createBatteryData(3.9);
provider.updateTelemetry(publicKey.sublist(0, 6), batteryOnly);
provider.updateFastGps(
publicKey.sublist(0, 6),
const FastGpsPacket(
senderKey6: '323334353637',
latitude: 44.123456,
longitude: 13.654321,
timestampSeconds: 1700001234,
),
);
final updated = provider.findContactByKey(publicKey)!;
expect(updated.telemetry, isNotNull);
expect(updated.telemetry!.gpsLocation, isNotNull);
expect(
updated.telemetry!.gpsLocation!.latitude,
closeTo(44.123456, 0.000001),
);
expect(
updated.telemetry!.gpsLocation!.longitude,
closeTo(13.654321, 0.000001),
);
expect(updated.telemetry!.batteryMilliVolts, isNotNull);
expect(updated.advLat, equals((44.123456 * 1e6).round()));
expect(updated.advLon, equals((13.654321 * 1e6).round()));
expect(updated.lastAdvert, equals(1700001234));
});
test('ignores unknown sender prefix safely', () {
final before = provider.findContactByKey(publicKey)!;
provider.updateFastGps(
Uint8List.fromList([1, 2, 3, 4, 5, 6]),
const FastGpsPacket(
senderKey6: '010203040506',
latitude: 10,
longitude: 20,
timestampSeconds: 99,
),
);
final after = provider.findContactByKey(publicKey)!;
expect(after.advLat, equals(before.advLat));
expect(after.advLon, equals(before.advLon));
});
});
}

View File

@@ -0,0 +1,37 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_sar_app/providers/helpers/message_delivery_tracker.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('MessageDeliveryTracker', () {
test('matches pending direct messages by contact', () {
final tracker = MessageDeliveryTracker();
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
final bob = Uint8List.fromList(List<int>.filled(32, 0xBB));
tracker.trackPendingDirectMessage('alice-1', alice);
tracker.trackPendingDirectMessage('bob-1', bob);
tracker.trackPendingDirectMessage('alice-2', alice);
expect(tracker.popPendingDirectMessageId(alice), 'alice-1');
expect(tracker.popPendingDirectMessageId(bob), 'bob-1');
expect(tracker.popPendingDirectMessageId(alice), 'alice-2');
});
test('removeByMessageId clears pending queue state', () {
final tracker = MessageDeliveryTracker();
final alice = Uint8List.fromList(List<int>.filled(32, 0xAA));
tracker.trackPendingDirectMessage('alice-1', alice);
tracker.mapAckTagToMessageId(42, 'alice-1');
tracker.removeByMessageId('alice-1');
expect(tracker.getMessageIdForAck(42), isNull);
expect(tracker.popPendingDirectMessageId(alice), isNull);
});
});
}

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