Compare commits

..

108 Commits

Author SHA1 Message Date
Janez T
99bb446ee9 Update iOS Runner project version 2026-03-11 16:31:53 +01:00
Janez T
088f3d37ef Update Portuguese wizard text 2026-03-11 16:07:38 +01:00
Janez T
7f200e5334 Update translations for all locales 2026-03-11 16:07:09 +01:00
Janez T
ae16417886 Resolve localization and name bugs 2026-03-11 15:54:00 +01:00
Janez T
467371946d Add channel slot detection fix 2026-03-11 15:30:31 +01:00
Janez T
2d23328ad0 Fix empty channel slot handling 2026-03-11 15:10:57 +01:00
Janez T
0dfb84bf4e Fix onboarding layout issues 2026-03-11 15:08:04 +01:00
Janez T
193a193e8a Ensure constant iOS location updates 2026-03-10 19:56:24 +01:00
Janez T
c1d1ec7d0c Enable iOS background GPS updates 2026-03-10 19:50:02 +01:00
Janez T
b415b63b4b Add GPX import export for trails 2026-03-10 13:52:37 +01:00
Janez T
818b9dbfb2 Review MeshCore payload status 2026-03-10 11:05:54 +01:00
Janez T
8085b89c07 Add repeaters map menu entry 2026-03-10 10:38:59 +01:00
Janez T
86771a3c55 Add fullscreen repeater map 2026-03-10 10:25:43 +01:00
Janez T
4b668ac2e9 Fix channel list layout behavior 2026-03-10 09:46:38 +01:00
Janez T
9deb563c05 Align and compact channel cards 2026-03-10 09:45:16 +01:00
Janez T
15ac3e2798 Align channel avatars and compact 2026-03-10 09:13:24 +01:00
Janez T
97addf7ba6 Hide last seen and tweak channels 2026-03-10 09:06:30 +01:00
Janez T
025bd737e7 Group contacts by last seen 2026-03-10 08:54:55 +01:00
Janez T
07b675dec6 Align repeater tile behavior 2026-03-10 08:48:04 +01:00
Janez T
d675ce18dd Use LogRxRouteDecoder in AppProvider 2026-03-10 08:43:58 +01:00
Janez T
a8e31bb2e8 Investigate DEVICE_QUERY timeout 2026-03-09 19:59:29 +01:00
Janez T
3cd96ba961 Fix scan command timeout 2026-03-09 19:53:36 +01:00
Janez T
a98fc6314e Analyze BLE MeshCore scan log 2026-03-09 19:45:17 +01:00
Janez T
ae1ea71f58 Analyze MeshCore BLE scan logs 2026-03-09 19:39:14 +01:00
Janez T
4dff79cd7b Update client ref and deps 2026-03-09 19:33:42 +01:00
Janez T
cbcb329c70 Fix NotificationService platform 2026-03-09 19:23:35 +01:00
Janez T
7abf96abf2 Point web build to meshcore-sar 2026-03-09 19:17:26 +01:00
Janez T
13bf74178c Update iOS TestFlight link 2026-03-09 16:31:42 +01:00
Janez T
66a00c9569 Remove sender name from notification 2026-03-08 21:12:08 +01:00
Janez T
b60fa008eb Fix ios notifications 2026-03-08 21:07:59 +01:00
Janez T
d113f7a881 Match sensors bg and hide counts 2026-03-08 21:03:44 +01:00
Janez T
2ab8681ac4 Add multilingual localization 2026-03-08 20:50:53 +01:00
Janez T
de5fb1e164 Translate missing localization 2026-03-08 20:46:09 +01:00
Janez T
dae7e67a8a Translate missing strings 2026-03-08 20:42:21 +01:00
Janez T
d51924021f Update German localization 2026-03-08 20:38:41 +01:00
Janez T
0f91292fd7 Update messaging options and UI 2026-03-08 20:34:38 +01:00
Janez T
b471ab0990 Add message font scale setting 2026-03-08 20:29:55 +01:00
Janez T
771cddb80d Display unread counts per channel 2026-03-08 20:22:10 +01:00
Janez T
bfd15065f0 Add message filters and counts 2026-03-08 20:15:48 +01:00
Janez T
f72db76538 Add reply shortcut for messages 2026-03-08 20:10:51 +01:00
Janez T
a96f96ca4c Add nearestrelay fallback settings 2026-03-08 20:05:24 +01:00
Janez T
9f62055eef Enable route hash settings 2026-03-08 17:00:54 +01:00
Janez T
6b2f18130c Add nearest relay fallback toggle 2026-03-08 16:56:57 +01:00
Janez T
922b40b755 Implement meshcore route rotation 2026-03-08 16:46:46 +01:00
Janez T
17d7c43745 Implement meshcore-open route reload 2026-03-08 14:26:05 +01:00
Janez T
e026c1dbbd Fix invalid simulator slice upload 2026-03-08 09:42:07 +01:00
Janez T
9ff24c1fcb Fix invalid objective_c framework 2026-03-08 09:40:10 +01:00
Janez Troha
948a22c4d2 Merge pull request #16 from MGJ520/main
Update Chinese translation
2026-03-08 09:33:25 +01:00
Janez T
05153c9ceb Clarify path_provider override 2026-03-08 09:30:30 +01:00
Janez T
cd508d5f74 Modernize device settings screen 2026-03-08 09:24:26 +01:00
Janez T
937f91e496 Preserve contact telemetry data 2026-03-08 09:23:07 +01:00
Janez T
1f826ae4c2 Set simple mode default 2026-03-08 09:19:13 +01:00
Janez T
2e15ccb3f3 Fix outdated packages 2026-03-08 09:11:30 +01:00
MGJ
aa0777d4a3 Update Chinese translation 2026-03-08 12:39:57 +08:00
Janez T
e904d53d8b Bump Flutter and iOS build versions 2026-03-07 21:13:44 +01:00
Janez T
c4aef05dcf Use flutter_map main source 2026-03-07 21:10:54 +01:00
Janez T
1c3af4f927 Upgrade flutter packages and limit 2026-03-07 20:59:01 +01:00
Janez T
fe2e08c4e4 Add faster location update channel 2026-03-07 20:38:49 +01:00
Janez T
3433eae3a6 Remove unused path_provider entry 2026-03-07 20:23:09 +01:00
Janez T
79c8664d04 Remove iOS critical alert flags 2026-03-07 20:09:07 +01:00
Janez T
162376cc47 Add ITSAppUsesNonExemptEncryption 2026-03-07 20:05:31 +01:00
Janez Troha
adb8fe32f4 Merge pull request #14 from ydev83/main
Add Russian localization
2026-03-07 20:03:45 +01:00
Janez T
5966ddfbc1 Fix annoying plist reset 2026-03-07 20:00:32 +01:00
Janez T
1867f815d2 Modernize contact list pills 2026-03-07 18:41:37 +01:00
Janez T
4010680da0 Fix contact route parse usage 2026-03-07 18:36:38 +01:00
Janez T
6bfc08f3ae Keep contact GPS location saved 2026-03-07 18:30:36 +01:00
Janez Troha
fa37a20fc7 Merge pull request #15 from MGJ520/main
Fix the Chinese translation switching issue
2026-03-07 18:08:49 +01:00
MGJ
09f61740d2 Merge remote-tracking branch 'origin/main' 2026-03-07 22:18:06 +08:00
MGJ
97c1f3b27d Fix the Chinese translation switching issue 2026-03-07 22:17:53 +08:00
Janez T
cf70aa2bfb Reduce header space and tweak chat 2026-03-07 14:53:22 +01:00
Janez T
40e4db7b7f Remove showDirectMessageDialog 2026-03-07 14:24:45 +01:00
Janez T
2c2e7b392a Remove showDirectMessageDialog usage 2026-03-07 14:24:10 +01:00
ydev83
fd7d6afb70 Merge branch 'dz0ny:main' into main 2026-03-07 23:18:51 +10:00
Janez T
3307a93640 Add avatars display on map 2026-03-07 14:17:52 +01:00
ydev83
8a4b29b968 Add files via upload 2026-03-07 23:15:15 +10:00
Janez T
0e4f727e26 Add swarm mode transport doc 2026-03-07 14:13:54 +01:00
Janez T
4e76898c8d Update pubspec version 2026-03-07 11:33:58 +01:00
Janez T
7214c73aea Remove gates and sync pubspec 2026-03-07 11:33:58 +01:00
Janez Troha
beb960cf62 Merge pull request #12 from MGJ520/main
Fix the Chinese translation switching issue
2026-03-07 09:32:32 +01:00
Janez T
30cac1bcbf Remove 100ms BLE command delay 2026-03-07 09:30:10 +01:00
Janez T
95e6aa35e2 Remove 100ms BLE command delay 2026-03-07 09:26:13 +01:00
Janez T
2bc0e21cf0 Remove 100ms BLE command delay 2026-03-07 09:23:29 +01:00
Janez T
f6c5a3a4ca Add transmission timing details 2026-03-07 09:05:55 +01:00
Janez T
810897d348 Refactor message header pill 2026-03-07 08:39:32 +01:00
Janez T
bb1c1f455d Refine message composer layout 2026-03-07 08:31:09 +01:00
Janez T
b0eded3780 Fix message bubble layout 2026-03-07 08:28:22 +01:00
Janez T
f1d56e2000 Refactor messages tab UI 2026-03-07 08:16:28 +01:00
MGJ
01afcd2ea7 Fix the Chinese translation switching issue 2026-03-07 11:22:21 +08:00
Janez T
70a6156e8a Update iOS project version 2026-03-06 21:22:51 +01:00
Janez T
1856dce27a Update iOS project version 2026-03-06 21:05:02 +01:00
Janez T
485ae995c3 Commit client and rerun pub get 2026-03-06 20:51:49 +01:00
Janez T
0e1a56d765 Log contact data without revealing 2026-03-06 20:47:02 +01:00
Janez T
ca8e8e6ecb Increase cache for public repeaters 2026-03-06 20:29:30 +01:00
Janez T
43f5702666 Add conservative ACK timeout 2026-03-06 20:27:05 +01:00
Janez T
85d0b26c67 Add retransmission handling 2026-03-06 20:12:12 +01:00
Janez T
7cd5351921 Improve message input layout 2026-03-06 20:01:56 +01:00
Janez T
96feb75712 feat: save all pending work
ref:
2026-03-06 20:01:56 +01:00
Janez T
f6779e8776 fix: clean up messages composer bar
ref:
2026-03-06 20:01:56 +01:00
Janez Troha
a9d062dc34 Merge pull request #11 from MGJ520/main
Fix the Chinese translation switching issue
2026-03-06 16:23:51 +01:00
MGJ
3680b38302 Fix the Chinese translation switching issue 2026-03-06 20:10:26 +08:00
Janez T
e3902cea1e fix: bump client lock
ref:
2026-03-05 20:58:58 +01:00
Janez T
2482f94cd0 Push meshcore_client and refresh pub 2026-03-05 20:58:29 +01:00
Janez T
2b34bc4162 fix: stop lpp zero tail
ref:
2026-03-05 20:12:09 +01:00
Janez T
03c0c7ad38 fix: safe scan notifications
ref:
2026-03-05 20:10:08 +01:00
Janez T
42fc1f9cd2 fix: defer dialog scan start
ref:
2026-03-05 20:08:36 +01:00
Janez T
7807ad7471 Compare meshcore repos for bugs 2026-03-05 19:54:49 +01:00
Janez T
310eee1ff5 Compare meshcore sar against open 2026-03-05 19:48:16 +01:00
Janez T
b8f03079e7 Fix channel data sending bug 2026-03-05 19:39:03 +01:00
169 changed files with 46988 additions and 28967 deletions

View File

@@ -12,7 +12,7 @@ permissions:
id-token: write id-token: write
env: env:
FLUTTER_VERSION: "3.35.6" FLUTTER_VERSION: "3.41.4"
APP_NAME: meshcore-sar APP_NAME: meshcore-sar
ANDROID_NDK_VERSION: "27.0.12077973" ANDROID_NDK_VERSION: "27.0.12077973"
@@ -280,7 +280,7 @@ jobs:
run: flutter pub get run: flutter pub get
- name: Build web release - name: Build web release
run: flutter build web --release --base-href /meshcore_sar_app/ run: flutter build web --release --base-href /meshcore-sar/
- name: Upload pages artifact - name: Upload pages artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v3

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -39,5 +39,13 @@ end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(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
end end

View File

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

View File

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

View File

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

View File

@@ -43,9 +43,11 @@
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>81</string> <string>111</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search &amp; Rescue operations</string> <string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search &amp; Rescue operations</string>
<key>NSBluetoothPeripheralUsageDescription</key> <key>NSBluetoothPeripheralUsageDescription</key>
@@ -100,6 +102,7 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>location</string> <string>location</string>
<string>bluetooth-central</string>
<string>bluetooth-peripheral</string> <string>bluetooth-peripheral</string>
</array> </array>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.428509"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.924905">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="102.185635"> <testcase classname="fastlane.lanes" name="2: build_app" time="113.366384">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="3.215591"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="755.542625">
</testcase> </testcase>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_pl.arb Normal file

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_pt.arb Normal file

File diff suppressed because it is too large Load Diff

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

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

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_tr.arb Normal file

File diff suppressed because it is too large Load Diff

2332
lib/l10n/app_uk.arb Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,7 @@
import 'dart:io' show Platform; import 'dart:async';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -14,11 +17,12 @@ import 'providers/channels_provider.dart';
import 'providers/voice_provider.dart'; import 'providers/voice_provider.dart';
import 'providers/image_provider.dart' as ip; import 'providers/image_provider.dart' as ip;
import 'providers/app_provider.dart'; import 'providers/app_provider.dart';
import 'providers/sensors_provider.dart';
import 'services/voice_codec_service.dart'; import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart'; import 'services/voice_player_service.dart';
import 'services/tile_cache_service.dart';
import 'services/notification_service.dart'; import 'services/notification_service.dart';
import 'services/locale_preferences.dart'; import 'services/locale_preferences.dart';
import 'services/mesh_map_nodes_service.dart';
import 'services/update_checker_service.dart'; import 'services/update_checker_service.dart';
import 'services/wizard_preferences.dart'; import 'services/wizard_preferences.dart';
import 'screens/home_screen.dart'; import 'screens/home_screen.dart';
@@ -70,6 +74,9 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Shows notification if update is available // Shows notification if update is available
_checkForUpdates(); _checkForUpdates();
// Refresh remote mesh node cache in background when the app starts.
unawaited(MeshMapNodesService.syncInBackgroundIfStale());
setState(() { setState(() {
_wizardCompleted = wizardCompleted; _wizardCompleted = wizardCompleted;
_isInitialized = true; _isInitialized = true;
@@ -158,7 +165,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
/// Shows notification if update is available /// Shows notification if update is available
Future<void> _checkForUpdates() async { Future<void> _checkForUpdates() async {
// Only check for updates on Android // Only check for updates on Android
if (!Platform.isAndroid) { if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
debugPrint('[UpdateChecker] Skipping update check (not Android)'); debugPrint('[UpdateChecker] Skipping update check (not Android)');
return; return;
} }
@@ -234,6 +241,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
}, },
), ),
ChangeNotifierProvider(create: (_) => ChannelsProvider()), ChangeNotifierProvider(create: (_) => ChannelsProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()),
// Voice provider (packet reassembly + playback) // Voice provider (packet reassembly + playback)
ChangeNotifierProvider( ChangeNotifierProvider(
@@ -246,18 +254,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Image provider (fragment reassembly + outgoing session cache) // Image provider (fragment reassembly + outgoing session cache)
ChangeNotifierProvider(create: (_) => ip.ImageProvider()), ChangeNotifierProvider(create: (_) => ip.ImageProvider()),
// Tile cache service
Provider(create: (_) => TileCacheService()),
// App provider that coordinates everything // App provider that coordinates everything
// VoiceProvider is read via context.read inside create since it's already registered above // VoiceProvider is read via context.read inside create since it's already registered above
ChangeNotifierProxyProvider6< ChangeNotifierProxyProvider5<
ConnectionProvider, ConnectionProvider,
ContactsProvider, ContactsProvider,
MessagesProvider, MessagesProvider,
DrawingProvider, DrawingProvider,
ChannelsProvider, ChannelsProvider,
TileCacheService,
AppProvider AppProvider
>( >(
create: (context) => AppProvider( create: (context) => AppProvider(
@@ -268,7 +272,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
channelsProvider: context.read<ChannelsProvider>(), channelsProvider: context.read<ChannelsProvider>(),
voiceProvider: context.read<VoiceProvider>(), voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(), imageProvider: context.read<ip.ImageProvider>(),
tileCacheService: context.read<TileCacheService>(),
), ),
update: update:
( (
@@ -278,7 +281,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
messages, messages,
drawings, drawings,
channels, channels,
tileCache,
previous, previous,
) => ) =>
previous ?? previous ??
@@ -290,7 +292,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
channelsProvider: channels, channelsProvider: channels,
voiceProvider: context.read<VoiceProvider>(), voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(), imageProvider: context.read<ip.ImageProvider>(),
tileCacheService: tileCache,
), ),
), ),
], ],
@@ -330,7 +331,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
// Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36) // Wrap in SafeArea for Android only to fix navigation bar overlap (API ≥36)
// iOS doesn't need SafeArea wrapping (causes extra black space at bottom) // iOS doesn't need SafeArea wrapping (causes extra black space at bottom)
if (Platform.isAndroid) { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
return SafeArea( return SafeArea(
left: false, left: false,
right: false, right: false,

View File

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

View File

@@ -73,12 +73,14 @@ class DeviceInfo {
final String? selfName; final String? selfName;
// Additional device capabilities (from RESP_CODE_DEVICE_INFO) // Additional device capabilities (from RESP_CODE_DEVICE_INFO)
final int? maxContacts; // Max contacts device supports final int? maxContacts; // Max contacts device supports
final int? maxChannels; // Max channels 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?
final int? blePin; // BLE PIN code telemetryModes; // Telemetry permission modes (bits 0-1: Base, bits 2-3: Location)
final int? multiAcks; // Extra ACK mode (0=no, 1=yes) final int? blePin; // BLE PIN code
final int? advertLocPolicy; // Location sharing policy (0=don't share, 1=share) final int? multiAcks; // Extra ACK mode (0=no, 1=yes)
final int?
advertLocPolicy; // Location sharing policy (0=don't share, 1=share)
// Firmware info // Firmware info
final int? firmwareVersion; final int? firmwareVersion;
@@ -88,6 +90,9 @@ class DeviceInfo {
// Repeat mode (firmware v9+) // Repeat mode (firmware v9+)
final bool? clientRepeat; final bool? clientRepeat;
final bool? supportsSpectrumScan;
final int? spectrumScanMinKhz;
final int? spectrumScanMaxKhz;
final List<({int lower, int upper})>? allowedRepeatFreqRanges; final List<({int lower, int upper})>? allowedRepeatFreqRanges;
DeviceInfo({ DeviceInfo({
@@ -124,6 +129,9 @@ class DeviceInfo {
this.manufacturerModel, this.manufacturerModel,
this.semanticVersion, this.semanticVersion,
this.clientRepeat, this.clientRepeat,
this.supportsSpectrumScan,
this.spectrumScanMinKhz,
this.spectrumScanMaxKhz,
this.allowedRepeatFreqRanges, this.allowedRepeatFreqRanges,
}); });
@@ -160,7 +168,9 @@ class DeviceInfo {
/// Get storage usage percentage (0-100) /// Get storage usage percentage (0-100)
double? get storageUsedPercent { double? get storageUsedPercent {
if (storageUsedKb == null || storageTotalKb == null || storageTotalKb == 0) { if (storageUsedKb == null ||
storageTotalKb == null ||
storageTotalKb == 0) {
return null; return null;
} }
return (storageUsedKb! / storageTotalKb!) * 100.0; return (storageUsedKb! / storageTotalKb!) * 100.0;
@@ -245,6 +255,9 @@ class DeviceInfo {
String? manufacturerModel, String? manufacturerModel,
String? semanticVersion, String? semanticVersion,
bool? clientRepeat, bool? clientRepeat,
bool? supportsSpectrumScan,
int? spectrumScanMinKhz,
int? spectrumScanMaxKhz,
List<({int lower, int upper})>? allowedRepeatFreqRanges, List<({int lower, int upper})>? allowedRepeatFreqRanges,
}) { }) {
return DeviceInfo( return DeviceInfo(
@@ -281,7 +294,11 @@ class DeviceInfo {
manufacturerModel: manufacturerModel ?? this.manufacturerModel, manufacturerModel: manufacturerModel ?? this.manufacturerModel,
semanticVersion: semanticVersion ?? this.semanticVersion, semanticVersion: semanticVersion ?? this.semanticVersion,
clientRepeat: clientRepeat ?? this.clientRepeat, 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/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@@ -10,6 +9,7 @@ enum MapLayerType {
googleHybrid, googleHybrid,
googleRoadmap, googleRoadmap,
googleTerrain, googleTerrain,
// Kept for stored preference compatibility after MBTiles removal.
vectorMbtiles, vectorMbtiles,
wmsBase, wmsBase,
} }
@@ -21,13 +21,6 @@ class MapLayer {
final String attribution; final String attribution;
final double maxZoom; final double maxZoom;
// Vector tile specific properties
final bool isVector;
final File? mbtilesFile;
final String? styleUrl;
final String? sourceName;
final bool? isGzipped;
// WMS specific properties // WMS specific properties
final bool isWms; final bool isWms;
final String? wmsBaseUrl; final String? wmsBaseUrl;
@@ -43,11 +36,6 @@ class MapLayer {
required this.urlTemplate, required this.urlTemplate,
required this.attribution, required this.attribution,
required this.maxZoom, required this.maxZoom,
this.isVector = false,
this.mbtilesFile,
this.styleUrl,
this.sourceName,
this.isGzipped,
this.isWms = false, this.isWms = false,
this.wmsBaseUrl, this.wmsBaseUrl,
this.wmsLayers, this.wmsLayers,
@@ -74,7 +62,7 @@ class MapLayer {
case MapLayerType.googleTerrain: case MapLayerType.googleTerrain:
return localizations.googleTerrain; return localizations.googleTerrain;
case MapLayerType.vectorMbtiles: case MapLayerType.vectorMbtiles:
// For vector tiles, use the name from metadata // Legacy value kept only for preference migration compatibility.
return name; return name;
case MapLayerType.wmsBase: case MapLayerType.wmsBase:
// For WMS layers, use the name (will be localized separately) // For WMS layers, use the name (will be localized separately)
@@ -182,28 +170,4 @@ class MapLayer {
static MapLayer fromType(MapLayerType type) { static MapLayer fromType(MapLayerType type) {
return allLayers.firstWhere((layer) => layer.type == type); return allLayers.firstWhere((layer) => layer.type == type);
} }
/// Create a MapLayer from an MBTiles file
static MapLayer fromMbtilesFile({
required String name,
required File mbtilesFile,
required String styleUrl,
required String sourceName,
required double maxZoom,
required bool isGzipped,
String? attribution,
}) {
return MapLayer(
type: MapLayerType.vectorMbtiles,
name: name,
urlTemplate: '', // Not used for vector tiles
attribution: attribution ?? 'MBTiles',
maxZoom: maxZoom,
isVector: true,
mbtilesFile: mbtilesFile,
styleUrl: styleUrl,
sourceName: sourceName,
isGzipped: isGzipped,
);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import '../models/contact.dart';
import '../models/device_info.dart'; import '../models/device_info.dart';
import '../models/room_login_state.dart'; import '../models/room_login_state.dart';
import '../models/sse_server_config.dart'; import '../models/sse_server_config.dart';
import 'package:meshcore_client/meshcore_client.dart'; import 'package:meshcore_client/meshcore_client.dart' hide Contact;
import '../services/sse_server_service.dart'; import '../services/sse_server_service.dart';
import '../utils/sar_message_parser.dart'; import '../utils/sar_message_parser.dart';
import 'helpers/room_login_manager.dart'; import 'helpers/room_login_manager.dart';
@@ -88,6 +90,8 @@ class ConnectionProvider with ChangeNotifier {
bool _isScanning = false; bool _isScanning = false;
bool get isScanning => _isScanning; bool get isScanning => _isScanning;
bool _isSpectrumScanActive = false;
bool get isSpectrumScanActive => _isSpectrumScanActive;
String? _error; String? _error;
String? get error => _error; String? get error => _error;
@@ -143,11 +147,15 @@ class ConnectionProvider with ChangeNotifier {
final MessageDeliveryTracker _messageDeliveryTracker = final MessageDeliveryTracker _messageDeliveryTracker =
MessageDeliveryTracker(); MessageDeliveryTracker();
final PingTracker _pingTracker = PingTracker(); final PingTracker _pingTracker = PingTracker();
final Map<String, Future<PingResult>> _pendingSmartPings = {};
// Expose room login states // Expose room login states
Map<String, RoomLoginState> get roomLoginStates => Map<String, RoomLoginState> get roomLoginStates =>
_roomLoginManager.roomLoginStates; _roomLoginManager.roomLoginStates;
bool isPingInProgress(Uint8List publicKey) =>
_pendingSmartPings.containsKey(_publicKeyToHex(publicKey));
// Callbacks for other providers // Callbacks for other providers
Function(Contact)? onContactReceived; Function(Contact)? onContactReceived;
Function(List<Contact>)? onContactsComplete; Function(List<Contact>)? onContactsComplete;
@@ -167,6 +175,12 @@ class ConnectionProvider with ChangeNotifier {
Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)? Function(String messageId, int expectedAckTag, int suggestedTimeoutMs)?
onMessageSent; onMessageSent;
Function(int ackCode, int roundTripTimeMs)? onMessageDelivered; Function(int ackCode, int roundTripTimeMs)? onMessageDelivered;
Future<Contact?> Function({
required String messageId,
required Contact contact,
required int retryAttempt,
})?
prepareDirectMessageSendCallback;
Function(String messageId, int echoCount, int snrRaw, int rssiDbm)? Function(String messageId, int echoCount, int snrRaw, int rssiDbm)?
onMessageEchoDetected; onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
@@ -246,7 +260,10 @@ class ConnectionProvider with ChangeNotifier {
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: 300));
if (pendingOp.messageId != null) { if (pendingOp.messageId != null) {
_messageDeliveryTracker.trackPendingMessage(pendingOp.messageId!); _messageDeliveryTracker.trackPendingDirectMessage(
pendingOp.messageId!,
pendingOp.contactPublicKey,
);
} }
await _activeService.sendTextMessage( await _activeService.sendTextMessage(
@@ -311,6 +328,10 @@ class ConnectionProvider with ChangeNotifier {
}; };
service.onMessageWaiting = () { service.onMessageWaiting = () {
if (_isSpectrumScanActive) {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return;
}
debugPrint('📥 [Provider] MSG_WAITING - auto-syncing'); debugPrint('📥 [Provider] MSG_WAITING - auto-syncing');
if (_isSyncingMessages) { if (_isSyncingMessages) {
_syncRequestedWhileBusy = true; _syncRequestedWhileBusy = true;
@@ -345,7 +366,11 @@ class ConnectionProvider with ChangeNotifier {
service.onMessageSent = service.onMessageSent =
(expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) { (expectedAckTag, suggestedTimeoutMs, isFloodMode, contactPublicKey) {
final messageId = _messageDeliveryTracker.popPendingMessageId(); final messageId = contactPublicKey != null
? _messageDeliveryTracker.popPendingDirectMessageId(
contactPublicKey,
)
: _messageDeliveryTracker.popPendingMessageId();
if (messageId != null) { if (messageId != null) {
_messageDeliveryTracker.mapAckTagToMessageId( _messageDeliveryTracker.mapAckTagToMessageId(
expectedAckTag, expectedAckTag,
@@ -378,6 +403,9 @@ class ConnectionProvider with ChangeNotifier {
manufacturerModel: deviceInfo['manufacturerModel'] as String?, manufacturerModel: deviceInfo['manufacturerModel'] as String?,
semanticVersion: deviceInfo['semanticVersion'] as String?, semanticVersion: deviceInfo['semanticVersion'] as String?,
clientRepeat: deviceInfo['clientRepeat'] as bool?, clientRepeat: deviceInfo['clientRepeat'] as bool?,
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
); );
notifyListeners(); notifyListeners();
if (_sseServer.isRunning) { if (_sseServer.isRunning) {
@@ -458,10 +486,18 @@ class ConnectionProvider with ChangeNotifier {
/// Start scanning for MeshCore devices /// Start scanning for MeshCore devices
Future<void> startScan() async { Future<void> startScan() async {
debugPrint('🔍 [Provider] startScan() called'); debugPrint('🔍 [Provider] startScan() called');
if (_deviceInfo.connectionState == ConnectionState.connecting ||
_deviceInfo.connectionState == ConnectionState.connected) {
debugPrint(
'⏭️ [Provider] Ignoring scan request while connection is active: ${_deviceInfo.connectionState}',
);
return;
}
_isScanning = true; _isScanning = true;
_scannedDevices.clear(); _scannedDevices.clear();
_error = null; _error = null;
notifyListeners(); _notifyListenersSafely();
debugPrint('✅ [Provider] Scan state initialized, notifying listeners'); debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
try { try {
@@ -477,7 +513,7 @@ class ConnectionProvider with ChangeNotifier {
debugPrint( debugPrint(
'✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}', '✅ [Provider] Added device to list: ${device.platformName} (RSSI: $rssi dBm), total: ${_scannedDevices.length}',
); );
notifyListeners(); _notifyListenersSafely();
} else { } else {
// Update RSSI if device already exists // Update RSSI if device already exists
final index = _scannedDevices.indexWhere( final index = _scannedDevices.indexWhere(
@@ -488,7 +524,7 @@ class ConnectionProvider with ChangeNotifier {
debugPrint( debugPrint(
' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm', ' 🔄 [Provider] Updated RSSI for ${device.platformName}: $rssi dBm',
); );
notifyListeners(); _notifyListenersSafely();
} else { } else {
debugPrint( debugPrint(
' ⏭️ [Provider] Device already in list with same RSSI, skipping', ' ⏭️ [Provider] Device already in list with same RSSI, skipping',
@@ -502,7 +538,7 @@ class ConnectionProvider with ChangeNotifier {
} finally { } finally {
debugPrint('🏁 [Provider] Scan completed'); debugPrint('🏁 [Provider] Scan completed');
_isScanning = false; _isScanning = false;
notifyListeners(); _notifyListenersSafely();
} }
} }
@@ -510,6 +546,18 @@ class ConnectionProvider with ChangeNotifier {
Future<void> stopScan() async { Future<void> stopScan() async {
await FlutterBluePlus.stopScan(); await FlutterBluePlus.stopScan();
_isScanning = false; _isScanning = false;
_notifyListenersSafely();
}
void _notifyListenersSafely() {
final phase = SchedulerBinding.instance.schedulerPhase;
if (phase == SchedulerPhase.transientCallbacks ||
phase == SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback((_) {
notifyListeners();
});
return;
}
notifyListeners(); notifyListeners();
} }
@@ -519,6 +567,11 @@ class ConnectionProvider with ChangeNotifier {
'🔵 [Provider] connect() called for device: ${device.platformName}', '🔵 [Provider] connect() called for device: ${device.platformName}',
); );
if (_isScanning) {
debugPrint('🔵 [Provider] Stopping active scan before connect()');
await stopScan();
}
_deviceInfo = _deviceInfo.copyWith( _deviceInfo = _deviceInfo.copyWith(
deviceId: device.remoteId.toString(), deviceId: device.remoteId.toString(),
deviceName: device.platformName.isNotEmpty deviceName: device.platformName.isNotEmpty
@@ -795,10 +848,26 @@ class ConnectionProvider with ChangeNotifier {
return true; return true;
} catch (e) { } catch (e) {
debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e'); debugPrint('❌ [Provider] Failed to check slot $channelIdx: $e');
if (_isEmptyChannelQueryError(e)) {
debugPrint(
' Treating slot $channelIdx as empty because the device reported it was not found',
);
return true;
}
return false; return false;
} }
} }
bool _isEmptyChannelQueryError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('not found');
}
bool _isChannelRefreshTimeoutError(Object error) {
final message = error.toString().toLowerCase();
return message.contains('0x1f') && message.contains('timed out');
}
Future<int?> findNextEmptyChannelSlot() async { Future<int?> findNextEmptyChannelSlot() async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
throw Exception('Not connected to device'); throw Exception('Not connected to device');
@@ -809,6 +878,35 @@ class ConnectionProvider with ChangeNotifier {
// maxChannels from device info, or default to 40 // maxChannels from device info, or default to 40
final maxChannels = _deviceInfo.maxChannels ?? 40; final maxChannels = _deviceInfo.maxChannels ?? 40;
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
// Match meshcore-open: trust the current synced channel list first and
// pick the first missing slot rather than probing every slot on-device.
if (getChannelInfo != null) {
final usedIndices = <int>{};
for (int i = 1; i < maxChannels; i++) {
final channel = getChannelInfo!(i);
if (channel == null) {
continue;
}
final channelName = (channel as dynamic).name as String?;
if (channelName != null && channelName.isNotEmpty) {
usedIndices.add(i);
}
}
for (int i = 1; i < maxChannels; i++) {
if (!usedIndices.contains(i)) {
debugPrint(' ✅ Found empty slot from synced channels: $i');
return i;
}
}
debugPrint(
' ⚠️ Synced channels report all custom slots occupied ($maxCustomChannels total)',
);
}
// Check each slot starting from 1 (skip 0 = public channel) // Check each slot starting from 1 (skip 0 = public channel)
for (int i = 1; i < maxChannels; i++) { for (int i = 1; i < maxChannels; i++) {
@@ -905,10 +1003,12 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Using existing slot: $slotIdx (overwrite mode)'); debugPrint(' Using existing slot: $slotIdx (overwrite mode)');
} else { } else {
// Find next empty slot for new channel // Find next empty slot for new channel
final maxChannels = _deviceInfo.maxChannels ?? 40;
final maxCustomChannels = maxChannels > 0 ? maxChannels - 1 : 0;
final emptySlot = await findNextEmptyChannelSlot(); final emptySlot = await findNextEmptyChannelSlot();
if (emptySlot == null) { if (emptySlot == null) {
throw Exception( throw Exception(
'All channel slots are in use (maximum 39 custom channels)', 'All channel slots are in use (maximum $maxCustomChannels custom channels)',
); );
} }
slotIdx = emptySlot; slotIdx = emptySlot;
@@ -929,23 +1029,33 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Secret converted to 16-byte key using MD5'); debugPrint(' Secret converted to 16-byte key using MD5');
} }
// Send CMD_SET_CHANNEL to radio // Send CMD_SET_CHANNEL to radio. Some devices accept the write but do not
await _activeService.setChannel( // reliably answer the follow-up CMD_GET_CHANNEL verification (0x1F).
channelIdx: slotIdx, try {
channelName: channelName, await _activeService.setChannel(
secret: secretBytes, channelIdx: slotIdx,
); channelName: channelName,
secret: secretBytes,
);
} catch (e) {
if (_isChannelRefreshTimeoutError(e)) {
debugPrint(
'⚠️ [Provider] CMD_GET_CHANNEL verification timed out after SET_CHANNEL; assuming the channel write succeeded',
);
onChannelInfoReceived?.call(
slotIdx,
channelName,
Uint8List.fromList(secretBytes),
null,
);
} else {
rethrow;
}
}
debugPrint( debugPrint(
'✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx', '✅ [Provider] Channel ${existingSlot != null ? 'updated' : 'created'} successfully in slot $slotIdx',
); );
// Small delay to allow the response to propagate
await Future.delayed(const Duration(milliseconds: 100));
// Refresh channels to update UI
// The channel info will be received via onChannelInfoReceived callback
await _activeService.getChannel(slotIdx);
} catch (e) { } catch (e) {
_error = 'Failed to create channel: $e'; _error = 'Failed to create channel: $e';
debugPrint('❌ [Provider] Channel creation failed: $e'); debugPrint('❌ [Provider] Channel creation failed: $e');
@@ -1039,7 +1149,7 @@ class ConnectionProvider with ChangeNotifier {
/// ///
/// [messageId] - optional message ID to track delivery status /// [messageId] - optional message ID to track delivery status
/// [contact] - optional contact object for path status logging /// [contact] - optional contact object for path status logging
/// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries) /// [retryAttempt] - retry attempt number (0 = first send, >0 = retries)
Future<bool> sendTextMessage({ Future<bool> sendTextMessage({
required Uint8List contactPublicKey, required Uint8List contactPublicKey,
required String text, required String text,
@@ -1053,6 +1163,17 @@ class ConnectionProvider with ChangeNotifier {
return false; return false;
} }
var effectiveContact = contact;
if (messageId != null &&
effectiveContact != null &&
prepareDirectMessageSendCallback != null) {
effectiveContact = await prepareDirectMessageSendCallback!(
messageId: messageId,
contact: effectiveContact,
retryAttempt: retryAttempt,
);
}
// CRITICAL: Check firmware ACK limit (8 max in circular buffer) // CRITICAL: Check firmware ACK limit (8 max in circular buffer)
// Rate limit at 7 to stay under the limit // Rate limit at 7 to stay under the limit
if (_messageDeliveryTracker.shouldRateLimit) { if (_messageDeliveryTracker.shouldRateLimit) {
@@ -1077,31 +1198,33 @@ class ConnectionProvider with ChangeNotifier {
try { try {
// Log path status and retry info // Log path status and retry info
if (contact != null) { if (effectiveContact != null) {
if (retryAttempt > 0) { if (retryAttempt > 0) {
debugPrint( debugPrint(
'🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)', '🔄 [ConnectionProvider] Sending message to ${effectiveContact.advName} (retry $retryAttempt)',
); );
} else { } else {
debugPrint( debugPrint(
'📤 [ConnectionProvider] Sending message to ${contact.advName}', '📤 [ConnectionProvider] Sending message to ${effectiveContact.advName}',
); );
} }
debugPrint(' Type: ${contact.type.displayName}'); debugPrint(' Type: ${effectiveContact.type.displayName}');
debugPrint(' Path status: ${contact.pathDescription}'); debugPrint(' Path status: ${effectiveContact.routeSummary}');
if (contact.hasPath) { if (effectiveContact.routeHasPath) {
debugPrint(' ✅ Using learned path (${contact.outPathLen} bytes)'); debugPrint(
' ✅ Using learned path (${effectiveContact.routeHopCount} hop(s), ${effectiveContact.routeHashSize}-byte hashes)',
);
} else { } else {
debugPrint(' ⚠️ No path available - will use flood mode'); debugPrint(' ⚠️ No path available - will use flood mode');
} }
} else if (retryAttempt > 0) { } else if (retryAttempt > 0) {
debugPrint( debugPrint(
'🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)', '🔄 [ConnectionProvider] Sending message (retry $retryAttempt)',
); );
} }
// Track pending operation for auto-recovery (if contact not found in radio) // Track pending operation for auto-recovery (if contact not found in radio)
if (contact != null) { if (effectiveContact != null) {
final operationId = contactPublicKey final operationId = contactPublicKey
.sublist(0, 6) .sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
@@ -1110,7 +1233,7 @@ class ConnectionProvider with ChangeNotifier {
contactPublicKey: contactPublicKey, contactPublicKey: contactPublicKey,
text: text, text: text,
messageId: messageId, messageId: messageId,
contact: contact, contact: effectiveContact,
retryAttempt: retryAttempt, retryAttempt: retryAttempt,
); );
debugPrint( debugPrint(
@@ -1126,7 +1249,10 @@ class ConnectionProvider with ChangeNotifier {
// The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping. // The MessagesProvider now uses simple ACK tag → recipientPublicKey mapping.
// We still track here for the SENT response callback to work. // We still track here for the SENT response callback to work.
if (messageId != null) { if (messageId != null) {
_messageDeliveryTracker.trackPendingMessage(messageId); _messageDeliveryTracker.trackPendingDirectMessage(
messageId,
contactPublicKey,
);
debugPrint(' 📝 Tracked pending message: $messageId'); debugPrint(' 📝 Tracked pending message: $messageId');
} }
@@ -1137,9 +1263,22 @@ class ConnectionProvider with ChangeNotifier {
attempt: retryAttempt, attempt: retryAttempt,
); );
if (messageId != null) {
Future.delayed(const Duration(milliseconds: 350), () {
if (_messageDeliveryTracker.hasAckForMessage(messageId)) {
return;
}
debugPrint(
' [ConnectionProvider] Missing RESP_CODE_SENT for $messageId; promoting to sent via fallback',
);
onMessageSent?.call(messageId, 0, 0);
});
}
// Clear pending operation after successful send (no error) // Clear pending operation after successful send (no error)
// If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically // If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically
if (contact != null) { if (effectiveContact != null) {
final operationId = contactPublicKey final operationId = contactPublicKey
.sublist(0, 6) .sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
@@ -1238,6 +1377,19 @@ class ConnectionProvider with ChangeNotifier {
); );
} }
/// Send a raw private zero-hop payload.
///
/// This wraps the raw custom transport using an empty path to match the
/// firmware's private multicast behavior.
Future<void> sendRawPrivateMulticast(Uint8List payload) async {
if (!_activeService.isConnected) return;
await _activeService.sendRawVoicePacket(
contactPathLen: 0,
contactPath: Uint8List(0),
payload: payload,
);
}
/// Request telemetry from contact /// Request telemetry from contact
/// ///
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39). /// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
@@ -1284,6 +1436,34 @@ class ConnectionProvider with ChangeNotifier {
required Uint8List contactPublicKey, required Uint8List contactPublicKey,
required bool hasPath, required bool hasPath,
Function()? onRetryWithFlooding, 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 { }) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
@@ -1302,7 +1482,10 @@ class ConnectionProvider with ChangeNotifier {
); );
// Send the ping // Send the ping
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); await _activeService.requestTelemetry(
contactPublicKey,
zeroHop: firstAttemptDirect,
);
// Wait for response or timeout // Wait for response or timeout
final bool gotResponse = await pingFuture; final bool gotResponse = await pingFuture;
@@ -1329,8 +1512,8 @@ class ConnectionProvider with ChangeNotifier {
wasDirectAttempt: false, wasDirectAttempt: false,
); );
// Retry with flooding (zeroHop=true acts as broadcast to neighbors) // Retry with flooding.
await _activeService.requestTelemetry(contactPublicKey, zeroHop: true); await _activeService.requestTelemetry(contactPublicKey, zeroHop: false);
// Wait for response or timeout // Wait for response or timeout
final bool gotRetryResponse = await retryFuture; final bool gotRetryResponse = await retryFuture;
@@ -1352,6 +1535,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) /// Send binary request to contact (modern replacement for requestTelemetry)
/// ///
/// Supports multiple request types: /// Supports multiple request types:
@@ -1542,6 +1729,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 /// Set transmit power
Future<void> setTxPower(int powerDbm) async { Future<void> setTxPower(int powerDbm) async {
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
@@ -1586,6 +1810,7 @@ class ConnectionProvider with ChangeNotifier {
/// Request fresh device info (triggers SelfInfo response) /// Request fresh device info (triggers SelfInfo response)
Future<void> refreshDeviceInfo() async { Future<void> refreshDeviceInfo() async {
if (_isSpectrumScanActive) return;
if (!_activeService.isConnected) { if (!_activeService.isConnected) {
_error = 'Not connected to device'; _error = 'Not connected to device';
notifyListeners(); notifyListeners();
@@ -1629,6 +1854,7 @@ class ConnectionProvider with ChangeNotifier {
/// Sync messages from device queue /// Sync messages from device queue
/// Call this repeatedly until no more messages are available /// Call this repeatedly until no more messages are available
Future<bool> syncNextMessage() async { Future<bool> syncNextMessage() async {
if (_isSpectrumScanActive) return false;
// Prevent re-entrancy and too-fast triggers // Prevent re-entrancy and too-fast triggers
if (_isSyncingMessages) { if (_isSyncingMessages) {
// Another sync (single or loop) is in progress // Another sync (single or loop) is in progress
@@ -1667,6 +1893,10 @@ class ConnectionProvider with ChangeNotifier {
/// Sync all waiting messages from device /// Sync all waiting messages from device
Future<int> syncAllMessages() async { Future<int> syncAllMessages() async {
if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0;
}
if (_isSyncingMessages) { if (_isSyncingMessages) {
// Already syncing; avoid overlapping loops // Already syncing; avoid overlapping loops
_syncRequestedWhileBusy = true; _syncRequestedWhileBusy = true;
@@ -1885,6 +2115,7 @@ class ConnectionProvider with ChangeNotifier {
} }
try { try {
_error = null;
await _activeService.resetPath(contactPublicKey); await _activeService.resetPath(contactPublicKey);
} catch (e) { } catch (e) {
_error = 'Failed to reset path: $e'; _error = 'Failed to reset path: $e';
@@ -1892,6 +2123,31 @@ class ConnectionProvider with ChangeNotifier {
} }
} }
Future<void> setContactRoute(
Contact contact, {
required int signedEncodedPathLen,
required Uint8List paddedPathBytes,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
_error = null;
final updatedContact = contact.copyWith(
outPathLen: signedEncodedPathLen,
outPath: Uint8List.fromList(paddedPathBytes),
);
await _activeService.addOrUpdateContact(updatedContact);
} catch (e) {
_error = 'Failed to set route: $e';
notifyListeners();
rethrow;
}
}
/// Remove a contact from the companion radio /// Remove a contact from the companion radio
/// ///
/// Deletes the contact from the device's internal contact table. /// Deletes the contact from the device's internal contact table.

View File

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

View File

@@ -0,0 +1,43 @@
import 'dart:async';
/// Tracks one or more in-flight waiters for the same fragment ACK key.
///
/// Duplicate fetch requests can race and wait on the same fragment ACK at once.
/// Completing all registered waiters avoids losing the earlier completer when a
/// later request registers for the same key.
class FragmentAckWaitRegistry {
final Map<String, List<Completer<void>>> _waiters = {};
Future<bool> waitFor(
String key, {
Duration timeout = const Duration(seconds: 8),
}) async {
final completer = Completer<void>();
final waiters = _waiters.putIfAbsent(key, () => <Completer<void>>[]);
waiters.add(completer);
try {
await completer.future.timeout(timeout);
return true;
} catch (_) {
final pending = _waiters[key];
pending?.remove(completer);
if (pending != null && pending.isEmpty) {
_waiters.remove(key);
}
return false;
}
}
int complete(String key) {
final waiters = _waiters.remove(key);
if (waiters == null || waiters.isEmpty) {
return 0;
}
for (final completer in waiters) {
if (!completer.isCompleted) {
completer.complete();
}
}
return waiters.length;
}
}

View File

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

View File

@@ -1,74 +1,69 @@
import 'dart:convert';
import '../../models/message.dart'; import '../../models/message.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
/// Manages message retry state and logic /// Manages message retry state and logic
/// ///
/// This helper class centralizes retry logic for direct messages, implementing /// This helper class centralizes retry logic for direct messages.
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
/// with learned routing paths.
/// ///
/// IMPORTANT: Based on MeshCore firmware analysis: /// IMPORTANT: Based on MeshCore firmware analysis:
/// - Firmware calculates timeout based on path length and airtime /// - Firmware calculates timeout based on path length and airtime
/// - Direct mode: ~(path_len * airtime * 2) + margin /// - Direct mode: ~(path_len * airtime * 2) + margin
/// - Flood mode: ~10-30 seconds for multi-hop /// - Flood mode: ~10-30 seconds for multi-hop
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths /// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
/// - Firmware does NOT automatically retry - app must implement /// - Firmware does NOT automatically retry - app must implement
class MessageRetryManager { class MessageRetryManager {
// Track retry state for each message ID // Track retry state for each message ID
final Map<String, int> _retryAttempts = {}; final Map<String, int> _retryAttempts = {};
final Map<String, DateTime> _lastRetryTimes = {}; final Map<String, DateTime> _lastRetryTimes = {};
final Map<String, int> _pathFailureStreaks = {};
// Progressive timeout values in milliseconds static const int maxRetryAttempts = 4;
// These are app-level timeouts, separate from firmware's suggested timeout
// Firmware timeout is for ACK arrival, these are for retry attempts
static const List<int> _timeouts = [4000, 8000, 12000];
/// Get timeout for a specific retry attempt (0-2) // Retry backoff values in milliseconds.
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2 static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
int getTimeoutForAttempt(int attempt) { static const int _defaultLoRaSf = 10;
if (attempt < 0 || attempt >= _timeouts.length) { static const int _defaultLoRaCr = 5;
return _timeouts.last; // Default to last timeout if out of range static const int _defaultLoRaBwHz = 250000;
static const int _defaultLoRaPreambleSymbols = 8;
static const int _defaultLoRaCrcEnabled = 1;
static const int _defaultLoRaExplicitHeader = 1;
/// Get backoff delay for the next retry attempt.
int getDelayForAttempt(int attempt) {
if (attempt < 0 || attempt >= _retryDelays.length) {
return _retryDelays.last;
} }
return _timeouts[attempt]; return _retryDelays[attempt];
}
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
/// provide one or returns an invalid value.
int calculateAckTimeoutMs({
required String text,
required Contact? contact,
int? suggestedTimeoutMs,
}) {
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
return suggestedTimeoutMs;
}
final payloadBytes = utf8.encode(text).length;
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
final hopCount = contact?.routeHasPath == true
? contact!.routeHopCount
: -1;
if (hopCount < 0) {
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
}
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
} }
/// Check if a message is eligible for retry
///
/// Returns true if:
/// - The message has retryAttempt < 3
/// - The contact has a learned path (contact.hasPath == true)
/// - The message hasn't used flood fallback yet
///
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
bool canRetry(Message message, Contact contact) { bool canRetry(Message message, Contact contact) {
// Never retry if already tried flood mode return message.retryAttempt < maxRetryAttempts;
if (message.usedFloodFallback) {
return false;
}
// Never retry beyond 3 attempts
if (message.retryAttempt >= 3) {
return false;
}
// Only retry if contact has a learned path
// If no path, the device uses flood mode automatically - retrying won't help
return contact.hasPath;
}
/// Check if should fall back to flood mode
///
/// Returns true if:
/// - Message has exhausted all 3 retry attempts with direct mode
/// - Contact HAS a learned path (so direct mode was used)
/// - Hasn't already used flood fallback
///
/// IMPORTANT: Only contacts WITH paths need flood fallback.
/// Contacts without paths already use flood mode automatically.
bool shouldUseFloodFallback(Message message, Contact contact) {
return message.retryAttempt >= 3 &&
contact.hasPath && // ✅ FIXED: Flood fallback for failed direct paths
!message.usedFloodFallback;
} }
/// Track a retry attempt for a message /// Track a retry attempt for a message
@@ -87,6 +82,7 @@ class MessageRetryManager {
void clearAll() { void clearAll() {
_retryAttempts.clear(); _retryAttempts.clear();
_lastRetryTimes.clear(); _lastRetryTimes.clear();
_pathFailureStreaks.clear();
} }
/// Get current retry attempt for a message (for debugging) /// Get current retry attempt for a message (for debugging)
@@ -98,4 +94,50 @@ class MessageRetryManager {
DateTime? getLastRetryTime(String messageId) { DateTime? getLastRetryTime(String messageId) {
return _lastRetryTimes[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) /// Mark a ping as successful (response received)
/// Should be called when telemetry response arrives /// Should be called when telemetry response arrives
void markPingSuccessful(Uint8List publicKey) { void markPingSuccessful(Uint8List publicKey) {
final String keyHex = _publicKeyToHex(publicKey); final requestKey = _findMatchingPendingPingKey(publicKey);
final request = _pendingPings.remove(keyHex); final request = requestKey != null ? _pendingPings.remove(requestKey) : null;
if (request != null) { if (request != null) {
request.cancel(); request.cancel();
@@ -81,6 +81,23 @@ class PingTracker {
String _publicKeyToHex(Uint8List publicKey) { String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join(''); return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
} }
String? _findMatchingPendingPingKey(Uint8List responseKey) {
final responseHex = _publicKeyToHex(responseKey);
if (_pendingPings.containsKey(responseHex)) {
return responseHex;
}
for (final entry in _pendingPings.entries) {
final pendingHex = entry.key;
if (pendingHex.startsWith(responseHex) || responseHex.startsWith(pendingHex)) {
return pendingHex;
}
}
return null;
}
} }
/// Internal class to track a single ping request /// Internal class to track a single ping request

View File

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

View File

@@ -0,0 +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<Message> messages,
) {
final voiceSenderKeyBySession = <String, String>{};
final imageSenderKeyBySession = <String, String>{};
final imageEnvelopeBySession = <String, ImageEnvelope>{};
for (final message in messages) {
final text = message.text;
final voiceEnvelope = VoiceEnvelope.tryParseText(text);
if (voiceEnvelope != null) {
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

@@ -58,6 +58,7 @@ class ImageProvider with ChangeNotifier {
/// Incoming sessions keyed by sessionId. /// Incoming sessions keyed by sessionId.
final Map<String, ImageSession> _sessions = {}; final Map<String, ImageSession> _sessions = {};
final Set<String> _ignoredIncomingSessions = {};
/// Outgoing sessions cached for deferred serving. /// Outgoing sessions cached for deferred serving.
final Map<String, _OutgoingSession> _outgoing = {}; final Map<String, _OutgoingSession> _outgoing = {};
@@ -69,12 +70,6 @@ class ImageProvider with ChangeNotifier {
required Uint8List payload, required Uint8List payload,
})? })?
sendRawPacketCallback; sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
ImageProvider() { ImageProvider() {
_restore(); _restore();
@@ -88,6 +83,8 @@ class ImageProvider with ChangeNotifier {
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId); bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
Duration? estimateRemainingTransferTime(String sessionId) => Duration? estimateRemainingTransferTime(String sessionId) =>
_sessions[sessionId]?.estimateRemaining(); _sessions[sessionId]?.estimateRemaining();
bool isReceiveCanceled(String sessionId) =>
_ignoredIncomingSessions.contains(sessionId);
List<int> missingFragmentIndices(String sessionId) { List<int> missingFragmentIndices(String sessionId) {
final session = _sessions[sessionId]; final session = _sessions[sessionId];
@@ -99,24 +96,51 @@ class ImageProvider with ChangeNotifier {
return missing; return missing;
} }
List<int> availableFragmentIndices(String sessionId) {
final outgoing = _outgoing[sessionId];
if (outgoing != null) {
return outgoing.fragments.map((fragment) => fragment.index).toList()
..sort();
}
final session = _sessions[sessionId];
if (session == null) return const [];
final indices = <int>[];
for (var i = 0; i < session.fragments.length; i++) {
if (session.fragments[i] != null) {
indices.add(i);
}
}
return indices;
}
// ── Incoming fragment reception ────────────────────────────────────────── // ── Incoming fragment reception ──────────────────────────────────────────
/// Add a received [fragment]. Creates the session on first fragment using /// Add a received [fragment]. New compact fragments rely on prior envelope
/// metadata from the fragment itself (requires envelope to have been /// metadata for total/format, while legacy fragments can still self-describe.
/// announced first; if not, defaults width/height to 0 — corrected on save).
/// ///
/// Returns true when the session just became complete. /// Returns true when the session just became complete.
bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) { bool addFragment(ImagePacket fragment, {int width = 0, int height = 0}) {
_sessions.putIfAbsent( if (_ignoredIncomingSessions.contains(fragment.sessionId)) {
fragment.sessionId, debugPrint(
() => ImageSession( '⏹️ [ImageProvider] Ignoring canceled incoming session ${fragment.sessionId}',
);
return false;
}
_sessions.putIfAbsent(fragment.sessionId, () {
if (fragment.total < 1) {
throw StateError(
'Image envelope missing for compact fragment ${fragment.sessionId}',
);
}
return ImageSession(
sessionId: fragment.sessionId, sessionId: fragment.sessionId,
format: fragment.format, format: fragment.format,
total: fragment.total, total: fragment.total,
width: width, width: width,
height: height, height: height,
), );
); });
final session = _sessions[fragment.sessionId]!; final session = _sessions[fragment.sessionId]!;
if (fragment.index < session.total) { if (fragment.index < session.total) {
@@ -135,9 +159,25 @@ class ImageProvider with ChangeNotifier {
return justComplete; return justComplete;
} }
void cancelIncomingSession(String sessionId) {
_ignoredIncomingSessions.add(sessionId);
_sessions.remove(sessionId);
unawaited(_persist());
notifyListeners();
}
void resumeIncomingSession(String sessionId) {
if (_ignoredIncomingSessions.remove(sessionId)) {
notifyListeners();
}
}
/// Register envelope metadata for a session (called when IE1 is received /// Register envelope metadata for a session (called when IE1 is received
/// before any binary fragments arrive). /// before any binary fragments arrive).
void registerEnvelope(ImageEnvelope envelope) { void registerEnvelope(ImageEnvelope envelope) {
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
return;
}
final existing = _sessions[envelope.sessionId]; final existing = _sessions[envelope.sessionId];
if (existing == null) { if (existing == null) {
_sessions[envelope.sessionId] = ImageSession( _sessions[envelope.sessionId] = ImageSession(
@@ -225,21 +265,26 @@ class ImageProvider with ChangeNotifier {
required Contact requester, required Contact requester,
Set<int>? requestedIndices, Set<int>? requestedIndices,
}) async { }) async {
final cached = _outgoing[sessionId]; final outgoing = _outgoing[sessionId];
if (cached == null) { final fragments = outgoing != null
debugPrint('⚠️ [ImageProvider] No cached session for $sessionId'); ? List<ImagePacket>.from(outgoing.fragments)
: _sessions[sessionId]?.fragments.whereType<ImagePacket>().toList() ??
const <ImagePacket>[];
if (fragments.isEmpty) {
debugPrint(
'⚠️ [ImageProvider] No cached or received session for $sessionId',
);
return false; return false;
} }
return serveCachedSessionFragments<ImagePacket>( return serveCachedSessionFragments<ImagePacket>(
providerLabel: 'ImageProvider', providerLabel: 'ImageProvider',
sessionId: sessionId, sessionId: sessionId,
requester: requester, requester: requester,
fragments: cached.fragments, fragments: fragments,
maxDirectPayloadHops: maxDirectPayloadHops, maxDirectPayloadHops: maxDirectPayloadHops,
indexOf: (fragment) => fragment.index, indexOf: (fragment) => fragment.index,
encodeBinary: (fragment) => fragment.encodeBinary(), encodeBinary: (fragment) => fragment.encodeBinary(),
sendRawPacket: sendRawPacketCallback, sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices, requestedIndices: requestedIndices,
); );
} }
@@ -249,6 +294,7 @@ class ImageProvider with ChangeNotifier {
Future<void> clearAll() async { Future<void> clearAll() async {
_sessions.clear(); _sessions.clear();
_outgoing.clear(); _outgoing.clear();
_ignoredIncomingSessions.clear();
notifyListeners(); notifyListeners();
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -59,6 +59,7 @@ class VoiceProvider with ChangeNotifier {
/// Active sessions keyed by sessionId. /// Active sessions keyed by sessionId.
final Map<String, VoiceSession> _sessions = {}; final Map<String, VoiceSession> _sessions = {};
final Set<String> _ignoredIncomingSessions = {};
/// Currently playing session ID, or null. /// Currently playing session ID, or null.
String? _playingSessionId; String? _playingSessionId;
@@ -70,12 +71,6 @@ class VoiceProvider with ChangeNotifier {
required Uint8List payload, required Uint8List payload,
})? })?
sendRawPacketCallback; sendRawPacketCallback;
Future<bool> Function({
required String sessionId,
required int index,
Duration timeout,
})?
waitForFragmentAckCallback;
final Map<String, _OutgoingVoiceSession> _outgoingSessions = {}; final Map<String, _OutgoingVoiceSession> _outgoingSessions = {};
@@ -117,6 +112,8 @@ class VoiceProvider with ChangeNotifier {
_outgoingSessions.containsKey(sessionId); _outgoingSessions.containsKey(sessionId);
Duration? estimateRemainingTransferTime(String sessionId) => Duration? estimateRemainingTransferTime(String sessionId) =>
_sessions[sessionId]?.estimateRemaining(); _sessions[sessionId]?.estimateRemaining();
bool isReceiveCanceled(String sessionId) =>
_ignoredIncomingSessions.contains(sessionId);
List<int> missingPacketIndices(String sessionId) { List<int> missingPacketIndices(String sessionId) {
final session = _sessions[sessionId]; final session = _sessions[sessionId];
@@ -128,19 +125,46 @@ class VoiceProvider with ChangeNotifier {
return missing; return missing;
} }
List<int> availablePacketIndices(String sessionId) {
final outgoing = _outgoingSessions[sessionId];
if (outgoing != null) {
return outgoing.packets.map((packet) => packet.index).toList()..sort();
}
final session = _sessions[sessionId];
if (session == null) return const [];
final indices = <int>[];
for (var i = 0; i < session.packets.length; i++) {
if (session.packets[i] != null) {
indices.add(i);
}
}
return indices;
}
// ── Packet reception ───────────────────────────────────────────────────── // ── Packet reception ─────────────────────────────────────────────────────
/// Add an incoming [packet] to its session. Creates the session on first packet. /// Add an incoming [packet] to its session. Creates the session on first packet.
/// Returns true if the session just became complete. /// Returns true if the session just became complete.
bool addPacket(VoicePacket packet) { bool addPacket(VoicePacket packet) {
_sessions.putIfAbsent( if (_ignoredIncomingSessions.contains(packet.sessionId)) {
packet.sessionId, debugPrint(
() => VoiceSession( '⏹️ [VoiceProvider] Ignoring canceled incoming session ${packet.sessionId}',
);
return false;
}
_sessions.putIfAbsent(packet.sessionId, () {
if (packet.total < 1) {
throw StateError(
'Voice envelope missing for compact packet ${packet.sessionId}',
);
}
return VoiceSession(
sessionId: packet.sessionId, sessionId: packet.sessionId,
mode: packet.mode, mode: packet.mode,
total: packet.total, total: packet.total,
), );
); });
final session = _sessions[packet.sessionId]!; final session = _sessions[packet.sessionId]!;
if (packet.index < session.total) { if (packet.index < session.total) {
@@ -159,6 +183,64 @@ class VoiceProvider with ChangeNotifier {
return justComplete; return justComplete;
} }
void cancelIncomingSession(String sessionId) {
_ignoredIncomingSessions.add(sessionId);
_sessions.remove(sessionId);
if (_playingSessionId == sessionId) {
unawaited(_player.stop());
_playingSessionId = null;
}
_persistVoiceData();
notifyListeners();
}
void resumeIncomingSession(String sessionId) {
if (_ignoredIncomingSessions.remove(sessionId)) {
notifyListeners();
}
}
void registerEnvelope(VoiceEnvelope envelope) {
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
return;
}
final existing = _sessions[envelope.sessionId];
if (existing == null) {
_sessions[envelope.sessionId] = VoiceSession(
sessionId: envelope.sessionId,
mode: envelope.mode,
total: envelope.total,
);
_persistVoiceData();
notifyListeners();
return;
}
final needsMerge =
existing.total != envelope.total || existing.mode != envelope.mode;
if (!needsMerge) {
notifyListeners();
return;
}
final merged = VoiceSession(
sessionId: envelope.sessionId,
mode: envelope.mode,
total: envelope.total,
);
merged.firstPacketAt = existing.firstPacketAt;
merged.lastPacketAt = existing.lastPacketAt;
for (final packet in existing.packets) {
if (packet == null) continue;
if (packet.index < merged.total) {
merged.packets[packet.index] = packet;
}
}
_sessions[envelope.sessionId] = merged;
_persistVoiceData();
notifyListeners();
}
/// Cache encoded packets for deferred voice serving. /// Cache encoded packets for deferred voice serving.
void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) { void cacheOutgoingSession(String sessionId, List<VoicePacket> packets) {
if (packets.isEmpty) return; if (packets.isEmpty) return;
@@ -175,10 +257,14 @@ class VoiceProvider with ChangeNotifier {
required Contact requester, required Contact requester,
Set<int>? requestedIndices, Set<int>? requestedIndices,
}) async { }) async {
final cached = _outgoingSessions[sessionId]; final outgoing = _outgoingSessions[sessionId];
if (cached == null) { final packets = outgoing != null
? List<VoicePacket>.from(outgoing.packets)
: _sessions[sessionId]?.packets.whereType<VoicePacket>().toList() ??
const <VoicePacket>[];
if (packets.isEmpty) {
debugPrint( debugPrint(
'⚠️ [VoiceProvider] No cached outgoing session for $sessionId', '⚠️ [VoiceProvider] No cached or received session for $sessionId',
); );
return false; return false;
} }
@@ -186,12 +272,11 @@ class VoiceProvider with ChangeNotifier {
providerLabel: 'VoiceProvider', providerLabel: 'VoiceProvider',
sessionId: sessionId, sessionId: sessionId,
requester: requester, requester: requester,
fragments: cached.packets, fragments: packets,
maxDirectPayloadHops: maxDirectPayloadHops, maxDirectPayloadHops: maxDirectPayloadHops,
indexOf: (packet) => packet.index, indexOf: (packet) => packet.index,
encodeBinary: (packet) => packet.encodeBinary(), encodeBinary: (packet) => packet.encodeBinary(),
sendRawPacket: sendRawPacketCallback, sendRawPacket: sendRawPacketCallback,
waitForFragmentAck: waitForFragmentAckCallback,
requestedIndices: requestedIndices, requestedIndices: requestedIndices,
); );
} }
@@ -237,6 +322,7 @@ class VoiceProvider with ChangeNotifier {
Future<void> clearStoredVoiceData() async { Future<void> clearStoredVoiceData() async {
_sessions.clear(); _sessions.clear();
_outgoingSessions.clear(); _outgoingSessions.clear();
_ignoredIncomingSessions.clear();
_playingSessionId = null; _playingSessionId = null;
notifyListeners(); notifyListeners();
try { try {

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'dart:io'; import 'dart:io';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:meshcore_client/meshcore_client.dart'; import 'package:meshcore_client/meshcore_client.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/route_hash_preferences.dart';
import '../utils/log_rx_route_decoder.dart';
class PacketLogScreen extends StatefulWidget { class PacketLogScreen extends StatefulWidget {
final MeshCoreBleService bleService; final MeshCoreBleService bleService;
@@ -580,6 +586,10 @@ class _PacketLogCard extends StatelessWidget {
), ),
), ),
], ],
if (isRx) ...[
const SizedBox(height: 12),
_DecodedRouteSection(log: log),
],
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -723,6 +733,259 @@ class _PacketLogCard extends StatelessWidget {
} }
} }
class _DecodedRouteSection extends StatelessWidget {
final BlePacketLog log;
const _DecodedRouteSection({required this.log});
@override
Widget build(BuildContext context) {
final contacts = context.watch<ContactsProvider>().contacts;
final connectionProvider = context.watch<ConnectionProvider>();
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
final ownName =
connectionProvider.deviceInfo.selfName ??
connectionProvider.deviceInfo.displayName;
return FutureBuilder<List<dynamic>>(
future: Future.wait<dynamic>([
RouteHashPreferences.getHashSize(),
MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
]),
builder: (context, snapshot) {
final decodedRoute = LogRxRouteDecoder.decode(
log.rawData,
preferredHashSize: snapshot.data?.first as int?,
);
if (decodedRoute == null) {
return const SizedBox.shrink();
}
final cachedNodes = snapshot.hasData
? snapshot.data![1] as List<MeshMapNode>
: const <MeshMapNode>[];
final resolvedPath = decodedRoute.hopHashes
.map(
(hashHex) => _resolveHashWithFallback(
hashHex,
contacts: contacts,
cachedNodes: cachedNodes,
ownPublicKey: ownPublicKey,
ownName: ownName,
),
)
.toList();
final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first;
return _RouteSection(
route: decodedRoute,
path: resolvedPath,
originalSender: originalSender,
);
},
);
}
ResolvedNodeHash _resolveHashWithFallback(
String hashHex, {
required List<Contact> contacts,
required List<MeshMapNode> cachedNodes,
required Uint8List? ownPublicKey,
required String? ownName,
}) {
final localResolved = LogRxRouteDecoder.resolveHash(
hashHex,
contacts: contacts,
ownPublicKey: ownPublicKey,
ownName: ownName,
);
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
return localResolved;
}
final normalizedHashHex = hashHex.toLowerCase();
final matches = cachedNodes
.where((node) => node.publicKey.startsWith(normalizedHashHex))
.toList();
if (matches.isEmpty) {
return localResolved;
}
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
return ResolvedNodeHash(
hashHex: normalizedHashHex,
label: matches.first.name,
isOwnNode: false,
isUniqueMatch: matches.length == 1,
matchCount: matches.length,
);
}
}
class _RouteSection extends StatelessWidget {
final DecodedLogRxRoute route;
final List<ResolvedNodeHash> path;
final ResolvedNodeHash? originalSender;
const _RouteSection({
required this.route,
required this.path,
required this.originalSender,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.alt_route, size: 16),
SizedBox(width: 6),
Text(
'Mesh Route',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
_FactCard(
icon: Icons.route,
label: 'Payload',
value: _payloadTypeLabel(route.payloadType),
),
_FactCard(
icon: Icons.hub,
label: 'Hops',
value: '${route.hopCount}',
),
_FactCard(
icon: Icons.tag,
label: 'Hash size',
value:
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
),
if (originalSender != null)
_FactCard(
icon: Icons.person_pin_circle,
label: 'Original sender',
value: _nodeLabel(originalSender!),
),
],
),
const SizedBox(height: 12),
if (path.isEmpty)
Text(
'Direct packet, no hop path attached.',
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (var i = 0; i < path.length; i++) ...[
_RouteHopChip(index: i + 1, node: path[i]),
if (i < path.length - 1)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 2),
child: Icon(Icons.arrow_right_alt, size: 16),
),
],
],
),
],
),
);
}
static String _payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'REQ';
case 0x01:
return 'RESP';
case 0x02:
return 'TXT';
case 0x03:
return 'ACK';
case 0x04:
return 'ADVERT';
case 0x05:
return 'GRP_TXT';
case 0x06:
return 'GRP_DATA';
case 0x07:
return 'ANON_REQ';
case 0x08:
return 'PATH';
case 0x09:
return 'TRACE';
case 0x0A:
return 'MULTIPART';
case 0x0B:
return 'CONTROL';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
}
}
static String _nodeLabel(ResolvedNodeHash node) {
if (node.isOwnNode) {
return '${node.label} (${node.hexLabel})';
}
if (node.matchCount == 0) {
return node.hexLabel;
}
if (node.isUniqueMatch) {
return '${node.label} (${node.hexLabel})';
}
return '${node.label} (${node.hexLabel}, ${node.matchCount} matches)';
}
}
class _RouteHopChip extends StatelessWidget {
final int index;
final ResolvedNodeHash node;
const _RouteHopChip({required this.index, required this.node});
@override
Widget build(BuildContext context) {
final color = node.isOwnNode
? Colors.blue
: node.isUniqueMatch
? Colors.green
: Colors.orange;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Text(
'$index. ${_RouteSection._nodeLabel(node)}',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
);
}
}
class _FactCard extends StatelessWidget { class _FactCard extends StatelessWidget {
final IconData icon; final IconData icon;
final String label; final String label;

View File

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

1115
lib/screens/sensors_tab.dart Normal file

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

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

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -58,6 +59,16 @@ class BackgroundLocationService {
return false; return false;
} }
if (Platform.isIOS && permission != LocationPermission.always) {
permission = await Geolocator.requestPermission();
if (permission != LocationPermission.always) {
debugPrint(
'⚠️ [BackgroundLocation] iOS background tracking requires Always permission',
);
return false;
}
}
// Save settings // Save settings
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefKeyEnabled, true); await prefs.setBool(_prefKeyEnabled, true);
@@ -68,8 +79,7 @@ class BackgroundLocationService {
try { try {
_positionSubscription = _positionSubscription =
Geolocator.getPositionStream( Geolocator.getPositionStream(
locationSettings: LocationSettings( locationSettings: _buildLocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceThreshold.toInt(), distanceFilter: distanceThreshold.toInt(),
), ),
).listen((Position position) async { ).listen((Position position) async {
@@ -171,4 +181,22 @@ class BackgroundLocationService {
await startTracking(distanceThreshold: distance); await startTracking(distanceThreshold: distance);
} }
} }
LocationSettings _buildLocationSettings({int distanceFilter = 10}) {
if (Platform.isIOS) {
return AppleSettings(
accuracy: LocationAccuracy.best,
activityType: ActivityType.fitness,
allowBackgroundLocationUpdates: true,
distanceFilter: distanceFilter,
pauseLocationUpdatesAutomatically: false,
showBackgroundLocationIndicator: true,
);
}
return LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: distanceFilter,
);
}
} }

View File

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

View File

@@ -25,6 +25,14 @@ class CayenneLppParser {
int fieldCount = 0; int fieldCount = 0;
while (reader.hasRemaining) { while (reader.hasRemaining) {
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) {
debugPrint(
' Detected zero-padded telemetry tail, stopping parse at position '
'${data.length - reader.remainingBytesCount}',
);
break;
}
try { try {
fieldCount++; fieldCount++;
debugPrint( debugPrint(
@@ -250,6 +258,14 @@ class CayenneLppParser {
return ((voltage - 3.0) / 1.2) * 100.0; return ((voltage - 3.0) / 1.2) * 100.0;
} }
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
final start = data.length - remainingBytes;
for (int i = start; i < data.length; i++) {
if (data[i] != 0) return false;
}
return remainingBytes > 0;
}
/// Create Cayenne LPP data for GPS location /// Create Cayenne LPP data for GPS location
/// Standard Cayenne LPP GPS format (type 0x88): /// Standard Cayenne LPP GPS format (type 0x88):
/// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000 /// - Latitude: 3 bytes, signed 24-bit, big-endian, × 10000

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,6 @@ class NotificationService {
requestAlertPermission: true, requestAlertPermission: true,
requestBadgePermission: true, requestBadgePermission: true,
requestSoundPermission: true, requestSoundPermission: true,
requestCriticalPermission: true, // For urgent SAR notifications
); );
// Combined initialization settings // Combined initialization settings
@@ -100,8 +99,6 @@ class NotificationService {
alert: true, alert: true,
badge: true, badge: true,
sound: true, sound: true,
critical:
true, // Request critical alert permission for urgent SAR notifications
); );
_permissionGranted = granted ?? false; _permissionGranted = granted ?? false;
debugPrint( debugPrint(
@@ -274,8 +271,7 @@ class NotificationService {
badgeNumber: 1, badgeNumber: 1,
threadIdentifier: 'sar_markers', threadIdentifier: 'sar_markers',
categoryIdentifier: 'SAR_ALERT', categoryIdentifier: 'SAR_ALERT',
interruptionLevel: interruptionLevel: InterruptionLevel.timeSensitive,
InterruptionLevel.critical, // Critical alert (bypasses silent mode)
); );
// Combined notification details // Combined notification details
@@ -406,10 +402,13 @@ class NotificationService {
(DateTime.now().millisecondsSinceEpoch % 1000); (DateTime.now().millisecondsSinceEpoch % 1000);
// Build notification title and body // Build notification title and body
final resolvedChannelName = channelName?.trim().isNotEmpty == true
? channelName!.trim()
: (localizations?.publicChannel ?? 'Public');
final title = isChannelMessage final title = isChannelMessage
? (localizations != null ? (localizations != null
? '${localizations.channel}: ${channelName ?? "Public"}' ? '${localizations.channel}: $resolvedChannelName'
: 'Channel: ${channelName ?? "Public"}') : 'Channel: $resolvedChannelName')
: (localizations != null : (localizations != null
? '${localizations.newMessage} ${localizations.from} $senderName' ? '${localizations.newMessage} ${localizations.from} $senderName'
: 'New message from $senderName'); : 'New message from $senderName');
@@ -554,7 +553,7 @@ class NotificationService {
final title = localizations?.updateAvailable ?? 'App Update Available'; final title = localizations?.updateAvailable ?? 'App Update Available';
final body = localizations != null final body = localizations != null
? '${localizations.currentVersion}: $currentVersion\n' ? '${localizations.currentVersion}: $currentVersion\n'
'${localizations.latestVersion}: $latestVersion' '${localizations.latestVersion}: $latestVersion'
: 'Current: $currentVersion\nLatest: $latestVersion'; : 'Current: $currentVersion\nLatest: $latestVersion';
// Android notification details // Android notification details

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ const int _maxCompanionFrameBytes = 172; // MeshCore MAX_FRAME_SIZE
const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen const int _cmdSendRawDataOverheadBytes = 2; // cmd + pathLen
const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD const int _maxMeshPacketPayloadBytes = 184; // MeshCore MAX_PACKET_PAYLOAD
const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload const int _meshPacketHeaderBytes = 2; // mesh header bytes before path/payload
const int _imagePacketHeaderBytes = 8; // image packet binary header in payload const int _imagePacketHeaderBytes = 6; // image packet binary header in payload
const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10) const int _defaultLoRaSf = 10; // MeshCore companion defaults (SF10)
const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5) const int _defaultLoRaCr = 5; // MeshCore companion defaults (4/5)
const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz) const int _defaultLoRaBwHz = 250000; // MeshCore companion defaults (250kHz)
@@ -31,7 +31,7 @@ enum ImageFormat {
/// A single binary fragment of a compressed image. /// A single binary fragment of a compressed image.
/// ///
/// Binary format (direct contacts, via pushRawData / cmdSendRawData): /// Binary format (direct contacts, via pushRawData / cmdSendRawData):
/// [0x49 'I'][sessionId:4B][fmt:1B][idx:1B][total:1B][imageData...] /// [0x49 'I'][sessionId:4B][idx:1B][imageData...]
/// ///
/// Legacy default is 152 data bytes per fragment. /// Legacy default is 152 data bytes per fragment.
class ImagePacket { class ImagePacket {
@@ -50,7 +50,7 @@ class ImagePacket {
}); });
static const int _magic = 0x49; // 'I' static const int _magic = 0x49; // 'I'
static const int _headerLen = 8; // magic(1)+session(4)+fmt(1)+idx(1)+total(1) static const int _headerLen = 6; // magic(1)+session(4)+idx(1)
static const int maxDataBytes = static const int maxDataBytes =
152; // Conservative default for compatibility. 152; // Conservative default for compatibility.
@@ -65,16 +65,14 @@ class ImagePacket {
.sublist(1, 5) .sublist(1, 5)
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(); .join();
final fmtId = payload[5]; final index = payload[5];
final index = payload[6]; final data = payload.sublist(_headerLen);
final total = payload[7];
if (total < 1) return null;
return ImagePacket( return ImagePacket(
sessionId: sessionId, sessionId: sessionId,
format: ImageFormat.fromId(fmtId), format: ImageFormat.avif,
index: index, index: index,
total: total, total: 0,
data: payload.sublist(_headerLen), data: data,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -92,16 +90,16 @@ class ImagePacket {
final out = Uint8List(_headerLen + data.length); final out = Uint8List(_headerLen + data.length);
out[0] = _magic; out[0] = _magic;
out.setRange(1, 5, sessionBytes); out.setRange(1, 5, sessionBytes);
out[5] = format.id; out[5] = index;
out[6] = index;
out[7] = total;
out.setRange(_headerLen, out.length, data); out.setRange(_headerLen, out.length, data);
return out; return out;
} }
@override @override
String toString() => String toString() {
'ImagePacket($sessionId ${format.label} [$index/${total - 1}] ${data.length}B)'; final suffix = total > 0 ? ' ${format.label} [$index/${total - 1}]' : ' [$index]';
return 'ImagePacket($sessionId$suffix ${data.length}B)';
}
} }
/// Compute the maximum safe image data bytes for a direct route path. /// Compute the maximum safe image data bytes for a direct route path.
@@ -125,7 +123,10 @@ int safeImageDataBytesForPath(int pathLen) {
? maxRawPayloadFromCommandFrame ? maxRawPayloadFromCommandFrame
: maxRawPayloadFromMesh; : maxRawPayloadFromMesh;
final maxData = maxRawPayload - ImagePacket._headerLen; final maxData = maxRawPayload - ImagePacket._headerLen;
return maxData.clamp(1, 255).toInt(); // Keep a safety margin below the theoretical direct-route ceiling.
// Fragments at the absolute 172-byte command-frame limit have proven flaky
// in practice, so cap to the conservative protocol default.
return maxData.clamp(1, ImagePacket.maxDataBytes).toInt();
} }
/// Approximate end-to-end transmit time for image fragments on MeshCore LoRa. /// Approximate end-to-end transmit time for image fragments on MeshCore LoRa.
@@ -243,11 +244,11 @@ int _resolveBandwidthHz(int? rawBw) {
/// Envelope announcing image availability (control plane). /// Envelope announcing image availability (control plane).
/// ///
/// Text format: /// Text format:
/// IE2:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts} /// IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
/// Example: /// Example:
/// IE2:deadbeef:0:7:3k:3k:t6:aabbccddeeff:s44we8 /// IE4:deadbeef:0:7:3k:3k:t6
class ImageEnvelope { class ImageEnvelope {
static const String _prefix = 'IE2:'; static const String _prefixV4 = 'IE4:';
final String sessionId; // 8 hex chars final String sessionId; // 8 hex chars
final ImageFormat format; final ImageFormat format;
@@ -255,8 +256,6 @@ class ImageEnvelope {
final int width; final int width;
final int height; final int height;
final int sizeBytes; // total compressed image size final int sizeBytes; // total compressed image size
final String senderKey6; // 12 hex chars (6 bytes)
final int timestampSec;
final int version; final int version;
const ImageEnvelope({ const ImageEnvelope({
@@ -266,18 +265,16 @@ class ImageEnvelope {
required this.width, required this.width,
required this.height, required this.height,
required this.sizeBytes, required this.sizeBytes,
required this.senderKey6, this.version = 4,
required this.timestampSec,
this.version = 2,
}); });
static bool isEnvelope(String text) => text.startsWith(_prefix); static bool isEnvelope(String text) => text.startsWith(_prefixV4);
static ImageEnvelope? tryParse(String text) { static ImageEnvelope? tryParse(String text) {
if (!isEnvelope(text)) return null; if (!isEnvelope(text)) return null;
final body = text.substring(_prefix.length); final body = text.substring(_prefixV4.length);
final parts = body.split(':'); final parts = body.split(':');
if (parts.length != 8) return null; if (parts.length != 6) return null;
try { try {
final sid = _decodeSessionId(parts[0]); final sid = _decodeSessionId(parts[0]);
final fmtId = _parseInt(parts[1], base36: true); final fmtId = _parseInt(parts[1], base36: true);
@@ -285,16 +282,12 @@ class ImageEnvelope {
final w = _parseInt(parts[3], base36: true); final w = _parseInt(parts[3], base36: true);
final h = _parseInt(parts[4], base36: true); final h = _parseInt(parts[4], base36: true);
final bytes = _parseInt(parts[5], base36: true); final bytes = _parseInt(parts[5], base36: true);
final senderKey6 = parts[6];
final ts = _parseInt(parts[7], base36: true);
if (sid == null) return null; if (sid == null) return null;
if (fmtId == null) return null; if (fmtId == null) return null;
if (total == null || total < 1 || total > 255) return null; if (total == null || total < 1 || total > 255) return null;
if (w == null || h == null || w < 1 || h < 1) return null; if (w == null || h == null || w < 1 || h < 1) return null;
if (bytes == null || bytes < 1) return null; if (bytes == null || bytes < 1) return null;
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(senderKey6)) return null;
if (ts == null || ts <= 0) return null;
return ImageEnvelope( return ImageEnvelope(
sessionId: sid, sessionId: sid,
@@ -303,9 +296,7 @@ class ImageEnvelope {
width: w, width: w,
height: h, height: h,
sizeBytes: bytes, sizeBytes: bytes,
senderKey6: senderKey6.toLowerCase(), version: 4,
timestampSec: ts,
version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -313,27 +304,25 @@ class ImageEnvelope {
} }
String encode() => String encode() =>
'$_prefix${_encodeSessionId(sessionId)}:' '$_prefixV4${_encodeSessionId(sessionId)}:'
'${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:' '${_toBase36(format.id)}:${_toBase36(total)}:${_toBase36(width)}:'
'${_toBase36(height)}:${_toBase36(sizeBytes)}:' '${_toBase36(height)}:${_toBase36(sizeBytes)}';
'${senderKey6.toLowerCase()}:${_toBase36(timestampSec)}';
} }
/// Direct request to fetch image fragments (control plane). /// Direct request to fetch image fragments (control plane).
/// ///
/// Text format: /// Text format:
/// IR2:{sid}:{want}:{requesterKey6}:{ts} /// IR4:{sid}:{want}:{requesterKey6}
/// Example: /// Example:
/// IR2:deadbeef:a:aabbccddeeff:s44wea /// IR4:deadbeef:a:aabbccddeeff
class ImageFetchRequest { class ImageFetchRequest {
static const String _prefix = 'IR2:'; static const String _prefixV4 = 'IR4:';
static const int _binaryMagic = 0x69; // 'i' static const int _binaryMagic = 0x69; // 'i'
final String sessionId; final String sessionId;
final String want; // 'all' or 'missing' final String want; // 'all' or 'missing'
final List<int> missingIndices; final List<int> missingIndices;
final String requesterKey6; // 12 hex chars final String requesterKey6; // 12 hex chars
final int timestampSec;
final int version; final int version;
const ImageFetchRequest({ const ImageFetchRequest({
@@ -341,29 +330,25 @@ class ImageFetchRequest {
this.want = 'all', this.want = 'all',
this.missingIndices = const [], this.missingIndices = const [],
required this.requesterKey6, required this.requesterKey6,
required this.timestampSec, this.version = 4,
this.version = 2,
}); });
static bool isRequest(String text) => text.startsWith(_prefix); static bool isRequest(String text) => text.startsWith(_prefixV4);
static bool isRequestBinary(Uint8List payload) => static bool isRequestBinary(Uint8List payload) =>
payload.isNotEmpty && payload[0] == _binaryMagic; payload.isNotEmpty && payload[0] == _binaryMagic;
static ImageFetchRequest? tryParse(String text) { static ImageFetchRequest? tryParse(String text) {
if (!isRequest(text)) return null; if (!isRequest(text)) return null;
final body = text.substring(_prefix.length); final body = text.substring(_prefixV4.length);
final parts = body.split(':'); final parts = body.split(':');
if (parts.length != 4) return null; if (parts.length != 3) return null;
try { try {
final sid = _decodeSessionId(parts[0]); final sid = _decodeSessionId(parts[0]);
final wantToken = parts[1]; final wantToken = parts[1];
final requesterKey6 = parts[2]; final requesterKey6 = parts[2];
final ts = _parseInt(parts[3], base36: true);
final normalizedWant = wantToken == 'a' final normalizedWant = wantToken == 'a'
? 'all' ? 'all'
: ((wantToken.startsWith('m')) : ((wantToken.startsWith('m')) ? 'missing' : wantToken);
? 'missing'
: wantToken);
if (sid == null) return null; if (sid == null) return null;
final missingIndices = <int>[]; final missingIndices = <int>[];
@@ -376,15 +361,13 @@ class ImageFetchRequest {
return null; return null;
} }
if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null; if (!RegExp(r'^[0-9a-fA-F]{12}$').hasMatch(requesterKey6)) return null;
if (ts == null || ts <= 0) return null;
return ImageFetchRequest( return ImageFetchRequest(
sessionId: sid, sessionId: sid,
want: normalizedWant, want: normalizedWant,
missingIndices: missingIndices, missingIndices: missingIndices,
requesterKey6: requesterKey6.toLowerCase(), requesterKey6: requesterKey6.toLowerCase(),
timestampSec: ts, version: 4,
version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -393,7 +376,7 @@ class ImageFetchRequest {
static ImageFetchRequest? tryParseBinary(Uint8List payload) { static ImageFetchRequest? tryParseBinary(Uint8List payload) {
if (!isRequestBinary(payload)) return null; if (!isRequestBinary(payload)) return null;
if (payload.length < 17) return null; // magic+sid+flags+key6+ts+count if (payload.length < 13) return null; // magic+sid+flags+key6+count
try { try {
final sid = payload final sid = payload
.sublist(1, 5) .sublist(1, 5)
@@ -406,25 +389,19 @@ class ImageFetchRequest {
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join() .join()
.toLowerCase(); .toLowerCase();
final ts = final missingCount = payload[12];
(payload[12] << 24) | if (payload.length != 13 + missingCount) return null;
(payload[13] << 16) |
(payload[14] << 8) |
payload[15];
final missingCount = payload[16];
if (payload.length != 17 + missingCount) return null;
final wantMissing = (flags & 0x01) == 0x01; final wantMissing = (flags & 0x01) == 0x01;
final missing = <int>[]; final missing = <int>[];
for (var i = 0; i < missingCount; i++) { for (var i = 0; i < missingCount; i++) {
missing.add(payload[17 + i]); missing.add(payload[13 + i]);
} }
return ImageFetchRequest( return ImageFetchRequest(
sessionId: sid, sessionId: sid,
want: wantMissing ? 'missing' : 'all', want: wantMissing ? 'missing' : 'all',
missingIndices: missing, missingIndices: missing,
requesterKey6: requesterKey6, requesterKey6: requesterKey6,
timestampSec: ts, version: 4,
version: 2,
); );
} catch (_) { } catch (_) {
return null; return null;
@@ -435,7 +412,7 @@ class ImageFetchRequest {
final wantToken = want == 'missing' && missingIndices.isNotEmpty final wantToken = want == 'missing' && missingIndices.isNotEmpty
? 'm${_encodeMissingIndicesCompact(missingIndices)}' ? 'm${_encodeMissingIndicesCompact(missingIndices)}'
: (want == 'all' ? 'a' : want); : (want == 'all' ? 'a' : want);
return '$_prefix${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}:${_toBase36(timestampSec)}'; return '$_prefixV4${_encodeSessionId(sessionId)}:$wantToken:${requesterKey6.toLowerCase()}';
} }
Uint8List encodeBinary() { Uint8List encodeBinary() {
@@ -454,7 +431,7 @@ class ImageFetchRequest {
? missingIndices.where((v) => v >= 0 && v <= 254).toList() ? missingIndices.where((v) => v >= 0 && v <= 254).toList()
: <int>[]; : <int>[];
final out = Uint8List(17 + missing.length); final out = Uint8List(13 + missing.length);
out[0] = _binaryMagic; out[0] = _binaryMagic;
for (var i = 0; i < 4; i++) { for (var i = 0; i < 4; i++) {
out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16); out[1 + i] = int.parse(sessionId.substring(i * 2, i * 2 + 2), radix: 16);
@@ -466,13 +443,9 @@ class ImageFetchRequest {
radix: 16, radix: 16,
); );
} }
out[12] = (timestampSec >> 24) & 0xFF; out[12] = missing.length;
out[13] = (timestampSec >> 16) & 0xFF;
out[14] = (timestampSec >> 8) & 0xFF;
out[15] = timestampSec & 0xFF;
out[16] = missing.length;
for (var i = 0; i < missing.length; i++) { for (var i = 0; i < missing.length; i++) {
out[17 + i] = missing[i]; out[13 + i] = missing[i];
} }
return out; return out;
} }
@@ -532,7 +505,11 @@ String _toBase36(int value) => value.toRadixString(36);
String _encodeSessionId(String sessionIdHex) { String _encodeSessionId(String sessionIdHex) {
if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) { if (!RegExp(r'^[0-9a-fA-F]{8}$').hasMatch(sessionIdHex)) {
throw ArgumentError.value(sessionIdHex, 'sessionIdHex', 'Expected 8 hex chars'); throw ArgumentError.value(
sessionIdHex,
'sessionIdHex',
'Expected 8 hex chars',
);
} }
final value = int.parse(sessionIdHex, radix: 16); final value = int.parse(sessionIdHex, radix: 16);
return value.toRadixString(36); return value.toRadixString(36);
@@ -546,10 +523,7 @@ String? _decodeSessionId(String token) {
} }
String _encodeMissingIndicesCompact(List<int> indices) { String _encodeMissingIndicesCompact(List<int> indices) {
final sorted = indices final sorted = indices.where((v) => v >= 0 && v <= 254).toSet().toList()
.where((v) => v >= 0 && v <= 254)
.toSet()
.toList()
..sort(); ..sort();
if (sorted.isEmpty) return ''; if (sorted.isEmpty) return '';
final chunks = <String>[]; final chunks = <String>[];
@@ -562,7 +536,9 @@ String _encodeMissingIndicesCompact(List<int> indices) {
continue; continue;
} }
chunks.add( chunks.add(
start == prev ? _toBase36(start) : '${_toBase36(start)}-${_toBase36(prev)}', start == prev
? _toBase36(start)
: '${_toBase36(start)}-${_toBase36(prev)}',
); );
start = curr; start = curr;
prev = curr; prev = curr;

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