mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Compare commits
294 Commits
v2026.0305
...
v2026.0404
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1baab06627 | ||
|
|
5320fa1a2b | ||
|
|
512b64db9f | ||
|
|
4690ceead1 | ||
|
|
d431a79aa8 | ||
|
|
d66bbca82c | ||
|
|
d2fee94678 | ||
|
|
61a856e84c | ||
|
|
5feb07aad7 | ||
|
|
59931f402a | ||
|
|
0cd563063a | ||
|
|
9244368b49 | ||
|
|
c089db5c62 | ||
|
|
7c1bc3b84f | ||
|
|
724f4b38e8 | ||
|
|
caefbdbcef | ||
|
|
79f8f8dd04 | ||
|
|
e4eb40cd34 | ||
|
|
7c9dfd6f63 | ||
|
|
3e8d4c2d11 | ||
|
|
3a28acac2d | ||
|
|
5ee03d4b2f | ||
|
|
3dfdbd7700 | ||
|
|
043faea06a | ||
|
|
05ddb6841a | ||
|
|
3392e5f9c1 | ||
|
|
7df750c8d5 | ||
|
|
e09d418084 | ||
|
|
96aeec1301 | ||
|
|
6978f24440 | ||
|
|
ba0f1fc141 | ||
|
|
28a9235168 | ||
|
|
3e3c9a34d5 | ||
|
|
e85dccea35 | ||
|
|
019224d955 | ||
|
|
4c048a5a50 | ||
|
|
2b42e7dd15 | ||
|
|
7ea6334f02 | ||
|
|
6f0a66dc68 | ||
|
|
a26ae96acd | ||
|
|
a6dc137e51 | ||
|
|
c2501442e3 | ||
|
|
71eac21df9 | ||
|
|
c299349b6a | ||
|
|
ab05f13de9 | ||
|
|
0e61dc5bf1 | ||
|
|
d45924d7b4 | ||
|
|
a24e699e22 | ||
|
|
7486837ad0 | ||
|
|
059125c714 | ||
|
|
9279ff2158 | ||
|
|
dc297b0b9f | ||
|
|
2d24481aba | ||
|
|
20a1df94eb | ||
|
|
5cc81bef65 | ||
|
|
6145462555 | ||
|
|
721fb7e7a2 | ||
|
|
5a58e45e13 | ||
|
|
de4258c0a4 | ||
|
|
d8cb35c595 | ||
|
|
8da69705ee | ||
|
|
e474168ba8 | ||
|
|
03dba8b3ce | ||
|
|
bc9e37bd22 | ||
|
|
a32d5d2c85 | ||
|
|
e683ab933f | ||
|
|
f286b0afd1 | ||
|
|
68ec72534d | ||
|
|
6af8b62d9b | ||
|
|
01fbb7de6c | ||
|
|
9303d24b69 | ||
|
|
140088fe2f | ||
|
|
67d8e1c906 | ||
|
|
29b75a3155 | ||
|
|
87cb890877 | ||
|
|
095458ece4 | ||
|
|
5659242672 | ||
|
|
3da4ba8fb2 | ||
|
|
89dc1f2862 | ||
|
|
251932bb47 | ||
|
|
b48139964a | ||
|
|
d29b85feb8 | ||
|
|
c01ef7a059 | ||
|
|
fa2b586ab7 | ||
|
|
bbd5d108cb | ||
|
|
e17f24a6bc | ||
|
|
372224a62c | ||
|
|
e4c8709ee2 | ||
|
|
3c469b62d0 | ||
|
|
1e97ba9831 | ||
|
|
6187067017 | ||
|
|
cd165d2372 | ||
|
|
59f6df4260 | ||
|
|
464fc5e715 | ||
|
|
a76ee4f8f6 | ||
|
|
c884f81ca5 | ||
|
|
993563f059 | ||
|
|
cfd233d38e | ||
|
|
9b14ff4e1a | ||
|
|
84cd8eec00 | ||
|
|
434d3df53e | ||
|
|
1fe260b95e | ||
|
|
d3d53995a6 | ||
|
|
efbddc8831 | ||
|
|
0e3acfa4ca | ||
|
|
450b8382e8 | ||
|
|
420690d683 | ||
|
|
fedd7b9a2b | ||
|
|
c82e0c3452 | ||
|
|
a55cfffa59 | ||
|
|
2bb5b353bd | ||
|
|
991421d097 | ||
|
|
1dcd85f178 | ||
|
|
5d2f61749e | ||
|
|
69377d90a4 | ||
|
|
3e559a4f4e | ||
|
|
a124e3d9f2 | ||
|
|
3bf4971279 | ||
|
|
e355f28ca9 | ||
|
|
7a9be3eb76 | ||
|
|
650f3c66aa | ||
|
|
df993cd176 | ||
|
|
feb3145854 | ||
|
|
da40acf950 | ||
|
|
66eb32bf44 | ||
|
|
c2e0fa952e | ||
|
|
4557fb4b9e | ||
|
|
c05de2cf0a | ||
|
|
c5145c2fca | ||
|
|
e502bc23b7 | ||
|
|
62bf33c9d1 | ||
|
|
7be1460ef6 | ||
|
|
30c273c656 | ||
|
|
5712feba53 | ||
|
|
6de12225fc | ||
|
|
6e9e9e397d | ||
|
|
a3147e9d7c | ||
|
|
5e1912d9f2 | ||
|
|
b42328bd82 | ||
|
|
1b801a10ef | ||
|
|
06632ee32d | ||
|
|
e7a481f97b | ||
|
|
9f31f50e61 | ||
|
|
d4237b7569 | ||
|
|
b35c11c804 | ||
|
|
be2cf53374 | ||
|
|
13a729492f | ||
|
|
9d8594fe05 | ||
|
|
249268dd00 | ||
|
|
63aa9619e6 | ||
|
|
a7ad4bb8ec | ||
|
|
636e114428 | ||
|
|
d5684c51c7 | ||
|
|
ced39b6992 | ||
|
|
083b715039 | ||
|
|
36584be0ab | ||
|
|
dc19296804 | ||
|
|
40072175e3 | ||
|
|
11f3bf9f97 | ||
|
|
24e715d01e | ||
|
|
6e17de3866 | ||
|
|
90877a1891 | ||
|
|
77e43843bf | ||
|
|
1e70f52069 | ||
|
|
5e404944e9 | ||
|
|
53daf2c9a7 | ||
|
|
c36d63a0ad | ||
|
|
69ec2dfdb5 | ||
|
|
d2e6b692f3 | ||
|
|
baa1aa6edd | ||
|
|
38cb3dc031 | ||
|
|
abb73a4c65 | ||
|
|
2d1e6c0ca4 | ||
|
|
b38a8c55d6 | ||
|
|
ca872f2049 | ||
|
|
1b6ff59af3 | ||
|
|
e31bfac2ac | ||
|
|
99bb446ee9 | ||
|
|
088f3d37ef | ||
|
|
7f200e5334 | ||
|
|
ae16417886 | ||
|
|
467371946d | ||
|
|
2d23328ad0 | ||
|
|
0dfb84bf4e | ||
|
|
193a193e8a | ||
|
|
c1d1ec7d0c | ||
|
|
b415b63b4b | ||
|
|
818b9dbfb2 | ||
|
|
8085b89c07 | ||
|
|
86771a3c55 | ||
|
|
4b668ac2e9 | ||
|
|
9deb563c05 | ||
|
|
15ac3e2798 | ||
|
|
97addf7ba6 | ||
|
|
025bd737e7 | ||
|
|
07b675dec6 | ||
|
|
d675ce18dd | ||
|
|
a8e31bb2e8 | ||
|
|
3cd96ba961 | ||
|
|
a98fc6314e | ||
|
|
ae1ea71f58 | ||
|
|
4dff79cd7b | ||
|
|
cbcb329c70 | ||
|
|
7abf96abf2 | ||
|
|
13bf74178c | ||
|
|
66a00c9569 | ||
|
|
b60fa008eb | ||
|
|
d113f7a881 | ||
|
|
2ab8681ac4 | ||
|
|
de5fb1e164 | ||
|
|
dae7e67a8a | ||
|
|
d51924021f | ||
|
|
0f91292fd7 | ||
|
|
b471ab0990 | ||
|
|
771cddb80d | ||
|
|
bfd15065f0 | ||
|
|
f72db76538 | ||
|
|
a96f96ca4c | ||
|
|
9f62055eef | ||
|
|
6b2f18130c | ||
|
|
922b40b755 | ||
|
|
17d7c43745 | ||
|
|
e026c1dbbd | ||
|
|
9ff24c1fcb | ||
|
|
948a22c4d2 | ||
|
|
05153c9ceb | ||
|
|
cd508d5f74 | ||
|
|
937f91e496 | ||
|
|
1f826ae4c2 | ||
|
|
2e15ccb3f3 | ||
|
|
aa0777d4a3 | ||
|
|
e904d53d8b | ||
|
|
c4aef05dcf | ||
|
|
1c3af4f927 | ||
|
|
fe2e08c4e4 | ||
|
|
3433eae3a6 | ||
|
|
79c8664d04 | ||
|
|
162376cc47 | ||
|
|
adb8fe32f4 | ||
|
|
5966ddfbc1 | ||
|
|
1867f815d2 | ||
|
|
4010680da0 | ||
|
|
6bfc08f3ae | ||
|
|
fa37a20fc7 | ||
|
|
09f61740d2 | ||
|
|
97c1f3b27d | ||
|
|
cf70aa2bfb | ||
|
|
40e4db7b7f | ||
|
|
2c2e7b392a | ||
|
|
fd7d6afb70 | ||
|
|
3307a93640 | ||
|
|
8a4b29b968 | ||
|
|
0e4f727e26 | ||
|
|
4e76898c8d | ||
|
|
7214c73aea | ||
|
|
beb960cf62 | ||
|
|
30cac1bcbf | ||
|
|
95e6aa35e2 | ||
|
|
2bc0e21cf0 | ||
|
|
f6c5a3a4ca | ||
|
|
810897d348 | ||
|
|
bb1c1f455d | ||
|
|
b0eded3780 | ||
|
|
f1d56e2000 | ||
|
|
01afcd2ea7 | ||
|
|
70a6156e8a | ||
|
|
1856dce27a | ||
|
|
485ae995c3 | ||
|
|
0e1a56d765 | ||
|
|
ca8e8e6ecb | ||
|
|
43f5702666 | ||
|
|
85d0b26c67 | ||
|
|
7cd5351921 | ||
|
|
96feb75712 | ||
|
|
f6779e8776 | ||
|
|
a9d062dc34 | ||
|
|
3680b38302 | ||
|
|
e3902cea1e | ||
|
|
2482f94cd0 | ||
|
|
2b34bc4162 | ||
|
|
03c0c7ad38 | ||
|
|
42fc1f9cd2 | ||
|
|
7807ad7471 | ||
|
|
310eee1ff5 | ||
|
|
b8f03079e7 | ||
|
|
ab0c531a4b | ||
|
|
9709511e90 | ||
|
|
162d5333ce | ||
|
|
0cb1d49804 | ||
|
|
234edf5bb0 | ||
|
|
2d9cb0ddb9 | ||
|
|
76b093685e | ||
|
|
55a85659d3 | ||
|
|
972a9ba944 |
25
.github/workflows/build-artifacts.yml
vendored
25
.github/workflows/build-artifacts.yml
vendored
@@ -12,7 +12,7 @@ permissions:
|
|||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: "3.35.6"
|
FLUTTER_VERSION: "3.41.4"
|
||||||
APP_NAME: meshcore-sar
|
APP_NAME: meshcore-sar
|
||||||
ANDROID_NDK_VERSION: "27.0.12077973"
|
ANDROID_NDK_VERSION: "27.0.12077973"
|
||||||
|
|
||||||
@@ -40,6 +40,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Cache Android NDK
|
- name: Cache Android NDK
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -95,6 +98,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Enable Linux desktop
|
- name: Enable Linux desktop
|
||||||
run: flutter config --enable-linux-desktop
|
run: flutter config --enable-linux-desktop
|
||||||
@@ -132,6 +138,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Enable Windows desktop
|
- name: Enable Windows desktop
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -174,6 +183,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Enable macOS desktop
|
- name: Enable macOS desktop
|
||||||
run: flutter config --enable-macos-desktop
|
run: flutter config --enable-macos-desktop
|
||||||
@@ -181,6 +193,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|
||||||
|
- name: Install macOS native build tools
|
||||||
|
run: brew install automake libtool
|
||||||
|
|
||||||
- name: Build macOS release
|
- name: Build macOS release
|
||||||
run: flutter build macos --release
|
run: flutter build macos --release
|
||||||
|
|
||||||
@@ -216,6 +231,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
@@ -272,6 +290,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: stable
|
channel: stable
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Enable web
|
- name: Enable web
|
||||||
run: flutter config --enable-web
|
run: flutter config --enable-web
|
||||||
@@ -280,7 +301,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 --wasm --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
|
||||||
|
|||||||
5
.github/workflows/build-multiplatform.yml
vendored
5
.github/workflows/build-multiplatform.yml
vendored
@@ -10,7 +10,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
FLUTTER_VERSION: '3.35.6'
|
FLUTTER_VERSION: '3.41.4'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
@@ -27,6 +27,9 @@ jobs:
|
|||||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
cache: true
|
cache: true
|
||||||
|
pub-cache: true
|
||||||
|
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
|
||||||
|
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: flutter pub get
|
run: flutter pub get
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -28,6 +28,7 @@ migrate_working_dir/
|
|||||||
**/ios/Flutter/.last_build_id
|
**/ios/Flutter/.last_build_id
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
|
pubspec_overrides.yaml
|
||||||
.pub-cache/
|
.pub-cache/
|
||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
@@ -78,12 +79,19 @@ play-store/
|
|||||||
create_feature_graphic.py
|
create_feature_graphic.py
|
||||||
|
|
||||||
# Tool caches and local state
|
# Tool caches and local state
|
||||||
|
.devenv/
|
||||||
.cachebro/
|
.cachebro/
|
||||||
.osgrep/
|
.osgrep/
|
||||||
|
third_party/lpcnet_flutter/
|
||||||
|
worker/node_modules/
|
||||||
|
worker/.wrangler/
|
||||||
|
worker/.bun/
|
||||||
|
worker/.astro/
|
||||||
|
worker/dist/
|
||||||
|
|
||||||
# Claude Code local settings (permissions, personal config)
|
# Claude Code local settings (permissions, personal config)
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
# Dart code coverage
|
# Dart code coverage
|
||||||
coverage/
|
coverage/
|
||||||
lcov.info
|
lcov.info
|
||||||
|
|||||||
25
CHANGELOG.md
Normal file
25
CHANGELOG.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-03-15 to 2026-03-22
|
||||||
|
|
||||||
|
- Device connectivity and routing: added Android USB serial companion support, serial compass support, GPS module controls, per-contact path size selection, and favourites support via firmware flags.
|
||||||
|
- Location and map intelligence: added RSSI-based repeater trilateration and contact location estimates, isolated MET history, matched the official advert import/discovery flow, and refreshed map presentation by removing contact trails.
|
||||||
|
- Telemetry and device info: surfaced self telemetry and sensor defaults, opened device info from signal and battery taps, improved signal chip behavior, fixed duplicate telemetry delivery, and restored telemetry refresh behavior.
|
||||||
|
- Contacts and messaging: added meshcore:// contact sharing and repeater neighbour views, supported MeshCore message links, fixed DM retry behavior, corrected room poster naming, and compacted the recipient selector.
|
||||||
|
- UI polish and platform upkeep: improved device settings layout, tightened sensor and contact presentation, expanded localization coverage, and refreshed platform dependencies and build numbers.
|
||||||
|
|
||||||
|
## Key PRs
|
||||||
|
|
||||||
|
- [#26](https://github.com/dz0ny/meshcore-sar/pull/26) Merge pull request #26 from `dz0ny/feat/sensor-telemetry-preview`
|
||||||
|
|
||||||
|
## 2026-03-08 to 2026-03-14
|
||||||
|
|
||||||
|
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.
|
||||||
|
- Messaging and channels: fixed channel sync, deduplication, overwrite and deletion issues, cleared message input after send, and added delayed-message warning tracking.
|
||||||
|
- Voice and transport: improved voice playback quality, improved scan and advert parsing, and kept route path data visible in logs.
|
||||||
|
- Maps and field operations: enabled iOS background GPS updates, added GPX trail import/export, introduced a fullscreen repeater map, and added live mesh traffic views.
|
||||||
|
- Localization and UI polish: added multilingual localization coverage, updated locale strings, fixed onboarding layout issues, and refined channel and contacts presentation.
|
||||||
|
|
||||||
|
## Key PRs
|
||||||
|
|
||||||
|
- [#16](https://github.com/dz0ny/meshcore-sar/pull/16) Merge pull request #16 from `MGJ520/main`
|
||||||
3
Makefile
3
Makefile
@@ -135,6 +135,9 @@ clean: ## Clean build artifacts
|
|||||||
flutter clean
|
flutter clean
|
||||||
rm -rf $(BUILD_DIR)
|
rm -rf $(BUILD_DIR)
|
||||||
|
|
||||||
|
build-web: ## Build web with WebAssembly
|
||||||
|
flutter build web --wasm --release
|
||||||
|
|
||||||
# Build for all platforms
|
# Build for all platforms
|
||||||
build-all: bump ## Build for Android and iOS
|
build-all: bump ## Build for Android and iOS
|
||||||
flutter build apk --release
|
flutter build apk --release
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -2,23 +2,28 @@
|
|||||||
|
|
||||||
## 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):**
|
||||||
- `IE1:` image envelope announces image availability in chat.
|
- `IE4:` image envelope announces image availability in chat.
|
||||||
- `IR1:` direct fetch request asks sender to stream image fragments.
|
- **Control plane (raw binary request):**
|
||||||
|
- Binary image fetch request (same raw route as image fragments).
|
||||||
- **Data plane (raw binary packets):**
|
- **Data plane (raw binary packets):**
|
||||||
- `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`.
|
- `ImagePacket` binary payload streamed via `cmdSendRawData` / `pushRawData`.
|
||||||
|
|
||||||
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` (`IE1`)
|
- `ImageEnvelope` (`IE4`)
|
||||||
- `ImageFetchRequest` (`IR1`)
|
- `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
|
||||||
- `lib/screens/messages_tab.dart`
|
- `lib/screens/messages_tab.dart`
|
||||||
@@ -26,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 `IE1`, `IR1`, 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
|
||||||
@@ -39,65 +45,64 @@ pixels are fetched on demand when the user taps the image bubble.
|
|||||||
|
|
||||||
## 3. Wire Formats
|
## 3. Wire Formats
|
||||||
|
|
||||||
### 3.1 Image Envelope (`IE1`)
|
### 3.1 Image Envelope (`IE4`)
|
||||||
|
|
||||||
Prefix: `IE1:` + colon-delimited payload
|
Prefix: `IE4:` + colon-delimited compact payload (base36 numeric fields)
|
||||||
|
|
||||||
Fields:
|
Fields:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|--------------|--------|------------------------------------------------|
|
|--------------|--------|------------------------------------------------|
|
||||||
| `sid` | string | 8 hex chars (4 bytes), session ID |
|
| `sid` | string | base36 token for 32-bit session ID |
|
||||||
| `fmt` | int | `ImageFormat.id` (0 = AVIF, 1 = JPEG) |
|
| `fmt` | base36 | `ImageFormat.id` (0 = AVIF, 1 = JPEG) |
|
||||||
| `total` | int | Fragment count (1..255) |
|
| `total` | base36 | Fragment count (1..255) |
|
||||||
| `w` | int | Actual image width after compression (pixels) |
|
| `w` | base36 | Actual image width after compression (pixels) |
|
||||||
| `h` | int | Actual image height after compression (pixels) |
|
| `h` | base36 | Actual image height after compression (pixels) |
|
||||||
| `bytes` | int | Total compressed size in bytes |
|
| `bytes` | base36 | Total compressed size in bytes |
|
||||||
| `senderKey6` | string | 12 hex chars (6 bytes sender prefix) |
|
|
||||||
| `ts` | int | Unix timestamp (seconds) |
|
|
||||||
| `ver` | int | Protocol version (currently `1`) |
|
|
||||||
|
|
||||||
Compact format:
|
Compact format:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IE1:{sid}:{fmt}:{total}:{w}:{h}:{bytes}:{senderKey6}:{ts}:{ver}
|
IE4:{sid}:{fmt}:{total}:{w}:{h}:{bytes}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example (256×171 landscape image, 14 fragments):
|
Example (256×171 landscape image, 14 fragments):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IE1:deadbeef:0:14:256:171:2100:aabbccddeeff:1700000000:1
|
IE4:a:0:e:74:4r:1mc
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: `w` and `h` reflect the actual post-compression dimensions, which preserve
|
Note: `sid` is base36 on wire and expands to 8-hex internally.
|
||||||
|
`w` and `h` reflect the actual post-compression dimensions, which preserve
|
||||||
the source aspect ratio (contain within the configured max size).
|
the source aspect ratio (contain within the configured max size).
|
||||||
|
|
||||||
### 3.2 Image Fetch Request (`IR1`)
|
### 3.2 Image Fetch Request (`IR4` + binary)
|
||||||
|
|
||||||
Same structure as `VR1`:
|
Text format:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
IR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
IR4:{sid}:{want}:{requesterKey6}
|
||||||
|
```
|
||||||
|
|
||||||
|
Binary payload format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[magic=0x69][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|------------------|--------------------------|
|
|------------------|--------------------------|
|
||||||
| `want` | `a` (= "all fragments") |
|
| `flags` | bit0=1 => request missing indices, else all |
|
||||||
| `requesterKey6` | 12 hex chars |
|
| `requesterKey6` | 6-byte requester key prefix |
|
||||||
| `ver` | `1` |
|
|
||||||
|
|
||||||
### 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
|
||||||
|
|
||||||
@@ -151,24 +156,34 @@ 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 (`IE1:` text, `deliveryStatus.sending`).
|
8. Local placeholder message added (`IE4:` text, `deliveryStatus.sending`).
|
||||||
|
|
||||||
## 6. Incoming Flow (Receive)
|
## 6. Incoming Flow (Receive)
|
||||||
|
|
||||||
### 6.1 `IE1` 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.
|
||||||
|
|
||||||
### 6.2 `IR1` request received
|
### 6.2 Binary image fetch request received
|
||||||
|
|
||||||
`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
|
||||||
@@ -187,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):
|
||||||
@@ -194,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 `IR1` 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.
|
||||||
|
|
||||||
@@ -212,11 +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 `IE1` envelope
|
- `total` fragments and `bytes` from `IE4` envelope
|
||||||
|
- 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:
|
||||||
@@ -244,11 +264,45 @@ 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.
|
||||||
|
|
||||||
## 12. High-Level Sequence
|
### 11.1 Raw Binary Routing Semantics
|
||||||
|
|
||||||
|
- Image fragment payloads use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`).
|
||||||
|
- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`).
|
||||||
|
- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`).
|
||||||
|
- This flow is direct-route only, not flood/broadcast:
|
||||||
|
- it is sent to one destination path;
|
||||||
|
- only nodes on that path relay it;
|
||||||
|
- it is **not** received by everyone in the mesh.
|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -260,10 +314,10 @@ 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 IE1 envelope (actual w×h, fragment count)
|
A->>M: Send IE4 envelope (actual w×h, fragment count)
|
||||||
M->>B: Deliver IE1
|
M->>B: Deliver IE4
|
||||||
B->>B: Render grey placeholder bubble
|
B->>B: Render grey placeholder bubble
|
||||||
B->>A: Tap → send IR1 fetch request
|
B->>A: Tap → send binary fetch request
|
||||||
A->>B: Stream binary ImagePackets
|
A->>B: Stream binary ImagePackets
|
||||||
B->>B: Reassemble fragments
|
B->>B: Reassemble fragments
|
||||||
B->>B: Display AVIF image (cover thumbnail)
|
B->>B: Display AVIF image (cover thumbnail)
|
||||||
|
|||||||
148
docs/swarm-mode-technical.md
Normal file
148
docs/swarm-mode-technical.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# Swarm Mode Technical Design
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
Swarm mode is a shared media-recovery transport used by both voice and image
|
||||||
|
sessions when the original sender path stops responding after some fragments
|
||||||
|
have already propagated through the mesh.
|
||||||
|
|
||||||
|
It adds a lightweight **swarm discovery plane** on top of the existing
|
||||||
|
**direct raw-data transfer plane**:
|
||||||
|
|
||||||
|
- **Discovery plane (raw custom control payloads):**
|
||||||
|
- `MediaSwarmRequest`
|
||||||
|
- `MediaSwarmAvailability`
|
||||||
|
- **Transfer plane (direct raw binary):**
|
||||||
|
- existing `VoiceFetchRequest` / `ImageFetchRequest`
|
||||||
|
- existing `VoicePacket` / `ImagePacket`
|
||||||
|
|
||||||
|
Swarm mode does not broadcast media payloads. It fans out raw control requests
|
||||||
|
to reachable peers, collects raw availability responses, and then fetches media
|
||||||
|
from the best responder.
|
||||||
|
|
||||||
|
## 2. Problem It Solves
|
||||||
|
|
||||||
|
Without swarm mode, media fetch is limited to the original sender's direct raw
|
||||||
|
path. If that sender goes offline, moves, or stops responding, a receiver can
|
||||||
|
be left with a partial session even when other peers already hold useful
|
||||||
|
fragments.
|
||||||
|
|
||||||
|
Swarm mode lets the receiver discover alternate peers and fetch the missing
|
||||||
|
subset directly from them.
|
||||||
|
|
||||||
|
## 3. Control Messages
|
||||||
|
|
||||||
|
### 3.1 Media Swarm Request (binary)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[magic=0x6d][kind=0x01][mediaType:1B][sessionId:4B][requesterKey6:6B][missingCount:1B][missingIndices...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `mediaType` — `voice` or `image`
|
||||||
|
- `sessionId` — 8 hex chars
|
||||||
|
- `requesterKey6` — 12 hex chars identifying the requesting device
|
||||||
|
- `missingCount` — `0` means the requester needs all fragments
|
||||||
|
- `missingIndices` — exact missing fragment indices when `missingCount > 0`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
6d 01 02 de ad be ef aa bb cc dd ee ff 03 00 02 09
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Media Swarm Availability (binary)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[magic=0x6d][kind=0x02][mediaType:1B][sessionId:4B][requesterKey6:6B][responderKey6:6B][availableCount:1B][availableIndices...]
|
||||||
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `mediaType` — `voice` or `image`
|
||||||
|
- `sessionId` — 8 hex chars
|
||||||
|
- `requesterKey6` — copied from the swarm request
|
||||||
|
- `responderKey6` — 12 hex chars identifying the responding peer
|
||||||
|
- `availableCount` — `0` means the responder can satisfy the full request
|
||||||
|
- `availableIndices` — exact fragment indices held when `availableCount > 0`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
6d 02 02 de ad be ef aa bb cc dd ee ff 11 22 33 44 55 66 02 02 09
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Requester Behavior
|
||||||
|
|
||||||
|
When a voice or image session is incomplete:
|
||||||
|
|
||||||
|
1. Prefer the original sender if its direct raw route is healthy.
|
||||||
|
2. If the original sender path is unavailable or not responding, send a raw
|
||||||
|
swarm request to reachable peers with the exact missing fragment indices.
|
||||||
|
3. Wait up to **10 seconds** for raw availability responses.
|
||||||
|
4. Rank responders by overlap with the current missing set.
|
||||||
|
5. Skip the original sender when choosing alternate peers.
|
||||||
|
6. Send a direct binary fetch request to the best responder for only the
|
||||||
|
missing subset that responder advertised.
|
||||||
|
|
||||||
|
Actual media transfer uses the same `cmdSendRawData` / `pushRawData` path as the
|
||||||
|
swarm control messages.
|
||||||
|
|
||||||
|
## 5. Responder Behavior
|
||||||
|
|
||||||
|
A peer that receives a swarm request:
|
||||||
|
|
||||||
|
- checks whether it has fragments for the requested media session
|
||||||
|
- replies only if it has at least one requested fragment
|
||||||
|
- advertises only fragments it actually holds
|
||||||
|
- may reply from:
|
||||||
|
- its outgoing cache, or
|
||||||
|
- a partially/fully received incoming session
|
||||||
|
|
||||||
|
This enables torrent-like relay behavior without requiring the peer to be the
|
||||||
|
original sender.
|
||||||
|
|
||||||
|
## 6. Visibility and Routing
|
||||||
|
|
||||||
|
- Swarm control payloads are carried by `cmdSendRawData` and received through
|
||||||
|
`pushRawData`.
|
||||||
|
- They are intercepted by the app and **must not be surfaced in chat**.
|
||||||
|
- Discovery is a direct raw fan-out to reachable peers, not a public-channel
|
||||||
|
broadcast.
|
||||||
|
- Media packets themselves remain direct-route raw packets.
|
||||||
|
|
||||||
|
## 7. Constraints
|
||||||
|
|
||||||
|
- No firmware changes are required.
|
||||||
|
- Swarm mode only helps if at least one peer has already received some useful
|
||||||
|
fragments.
|
||||||
|
- Peers can only relay fragments they actually have.
|
||||||
|
- Alternate peers still need a valid direct raw route back to the requester.
|
||||||
|
- Swarm mode improves recovery from sender-path failure, but it does not change
|
||||||
|
the underlying raw-packet size, airtime, or hop constraints.
|
||||||
|
|
||||||
|
## 8. High-Level Sequence
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant S as Original Sender
|
||||||
|
participant P as Peer With Fragments
|
||||||
|
participant N as Reachable Peers
|
||||||
|
participant R as Requester
|
||||||
|
|
||||||
|
S->>R: Direct raw fragments (partial)
|
||||||
|
Note over S,R: Original sender path stops responding
|
||||||
|
R->>N: Raw swarm requests with exact missing fragments
|
||||||
|
P->>R: Raw swarm availability response
|
||||||
|
R->>P: Direct binary fetch request for missing subset
|
||||||
|
P->>R: Direct raw fragments
|
||||||
|
R->>R: Reassemble completed session
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Integration Points
|
||||||
|
|
||||||
|
- Voice-specific behavior is summarized in `docs/voice-mode-technical.md`.
|
||||||
|
- Image-specific behavior is summarized in `docs/image-mode-technical.md`.
|
||||||
|
- This document is the shared source of truth for swarm discovery and fallback
|
||||||
|
semantics.
|
||||||
@@ -2,87 +2,74 @@
|
|||||||
|
|
||||||
## 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):**
|
||||||
- `VE1:` voice envelope announces voice availability in chat.
|
- `VE3:` voice envelope announces voice availability in chat.
|
||||||
- `VR1:` direct fetch request asks sender to stream voice payload.
|
- **Control plane (raw binary request):**
|
||||||
|
- Binary voice fetch request (same raw route as voice packets).
|
||||||
- **Data plane (raw binary packets):**
|
- **Data plane (raw binary packets):**
|
||||||
- `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`.
|
- `VoicePacket` payload streamed via `cmdSendRawData` and received through `pushRawData`.
|
||||||
|
|
||||||
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` (`VE1`)
|
- `VoiceEnvelope` (`VE3`)
|
||||||
- `VoiceFetchRequest` (`VR1`)
|
- `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
|
||||||
- `lib/providers/voice_provider.dart`
|
- `lib/providers/voice_provider.dart`
|
||||||
- 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 `VE1` and `VR1`
|
- 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 (`VE1` + 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 (`VE1`)
|
### 3.1 Voice Envelope (`VE3`)
|
||||||
|
|
||||||
Prefix: `VE1:` + colon-delimited compact payload
|
Prefix: `VE3:` + colon-delimited compact payload (base36 numeric fields)
|
||||||
|
|
||||||
Fields:
|
Fields:
|
||||||
|
|
||||||
- `sid` (string, 8 hex chars): session ID
|
- `sid` (string): base36 token for 32-bit session ID
|
||||||
- `mode` (int): codec mode ID (`VoicePacketMode.id`)
|
- `mode` (base36): codec mode ID (`VoicePacketMode.id`)
|
||||||
- `total` (int): packet count (1..255)
|
- `total` (base36): packet count (1..255)
|
||||||
- `durMs` (int): estimated duration in ms
|
- `durS` (base36): estimated duration in seconds
|
||||||
- `senderKey6` (string, 12 hex chars): sender public-key prefix (6 bytes)
|
|
||||||
- `ts` (int): unix timestamp seconds
|
`sid` is base36 on wire and expands to 8-hex internally.
|
||||||
- `ver` (int): protocol version (currently `1`)
|
|
||||||
|
|
||||||
Compact format:
|
Compact format:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
VE1:{sid}:{mode}:{total}:{durMs}:{senderKey6}:{ts}:{ver}
|
VE3:{sid}:{mode}:{total}:{durS}
|
||||||
```
|
```
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
VE1:deadbeef:1:4:3200:aabbccddeeff:1700000000:1
|
VE3:a:1:4:4
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.2 Voice Fetch Request (`VR1`)
|
### 3.2 Voice Fetch Request (binary)
|
||||||
|
|
||||||
Prefix: `VR1:` + colon-delimited compact payload
|
Binary payload format:
|
||||||
|
|
||||||
Fields:
|
|
||||||
|
|
||||||
- `sid` (string, 8 hex chars): requested session
|
|
||||||
- `want` (string): currently `a` (compact token for `all`)
|
|
||||||
- `requesterKey6` (string, 12 hex chars): requester key prefix
|
|
||||||
- `ts` (int): unix timestamp seconds
|
|
||||||
- `ver` (int): protocol version (`1`)
|
|
||||||
|
|
||||||
Compact format:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
VR1:{sid}:{want}:{requesterKey6}:{ts}:{ver}
|
[magic=0x72][sid:4B][flags:1B][requesterKey6:6B][missingCount:1B][missingIndices...]
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
VR1:deadbeef:a:112233445566:1700000010:1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 Raw Voice Packet (data plane)
|
### 3.3 Raw Voice Packet (data plane)
|
||||||
@@ -91,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)
|
||||||
|
|
||||||
@@ -102,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 (`VE1`) 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 `VE1` 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 `VR1` 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`.
|
||||||
|
|
||||||
@@ -132,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 `VE1.senderKey6` fallback)
|
1. Resolve sender contact from message sender metadata
|
||||||
2. Send direct `VR1` 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"**.
|
||||||
|
|
||||||
@@ -150,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:
|
||||||
@@ -170,10 +176,9 @@ Parser validation enforces:
|
|||||||
- strict hex lengths for IDs and key prefixes
|
- strict hex lengths for IDs and key prefixes
|
||||||
- valid mode range
|
- valid mode range
|
||||||
- valid packet counts and duration bounds
|
- valid packet counts and duration bounds
|
||||||
- fixed protocol version (`ver == 1`)
|
- compact base36 numeric fields in envelope/request
|
||||||
- `VR1.want` token `a` (internally normalized to `all`)
|
- Binary request flags specify `all` or `missing` indices.
|
||||||
|
- Request payload includes `requesterKey6` to resolve return route.
|
||||||
`VR1` handling verifies sender prefix matches `requesterKey6` to reduce spoofing risk.
|
|
||||||
|
|
||||||
## 10. Transmit Time Estimate (UI)
|
## 10. Transmit Time Estimate (UI)
|
||||||
|
|
||||||
@@ -182,12 +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 `VE1` envelope, or
|
- `packetCount` and `durationMs` from `VE3` envelope, or
|
||||||
|
- numeric envelope values decoded from base36
|
||||||
- actual received `VoicePacket.codec2Data.length` bytes when local session packets exist
|
- 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:
|
||||||
@@ -206,30 +212,44 @@ 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.
|
||||||
|
|
||||||
## 12. Backward Compatibility
|
### 11.1 Raw Binary Routing Semantics
|
||||||
|
|
||||||
- Legacy `V:` text packet parsing is still supported.
|
- Voice payload packets use companion command `CMD_SEND_RAW_DATA` (`25` / `0x19`).
|
||||||
- Message voice detection accepts both new `VE1` and legacy `V:` formats.
|
- Companion push back to the app is `PUSH_CODE_RAW_DATA` (`0x84`).
|
||||||
|
- Over-the-air packet type for this flow is `PAYLOAD_TYPE_RAW_CUSTOM` (`0x0F`).
|
||||||
|
- This flow is direct-route only, not flood/broadcast:
|
||||||
|
- it is sent to one destination path;
|
||||||
|
- only nodes on that path relay it;
|
||||||
|
- it is **not** received by everyone in the mesh.
|
||||||
|
|
||||||
## 13. High-Level Sequence
|
## 12. 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 VE1 envelope
|
A->>M: Send VE3 envelope
|
||||||
M->>B: Deliver VE1
|
M->>B: Deliver VE3
|
||||||
B->>B: Render voice bubble (metadata only)
|
B->>B: Render voice bubble (metadata only)
|
||||||
B->>A: Send VR1 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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
@@ -85,6 +84,8 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- vibration (3.0.0):
|
- vibration (3.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- wakelock_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
||||||
@@ -100,8 +101,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`)
|
||||||
@@ -109,12 +110,12 @@ DEPENDENCIES:
|
|||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
- vibration (from `.symlinks/plugins/vibration/ios`)
|
- vibration (from `.symlinks/plugins/vibration/ios`)
|
||||||
|
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
- DKImagePickerController
|
- DKImagePickerController
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
- ObjectBox
|
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SwiftyGif
|
- SwiftyGif
|
||||||
|
|
||||||
@@ -145,10 +146,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:
|
||||||
@@ -163,9 +164,11 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
vibration:
|
vibration:
|
||||||
:path: ".symlinks/plugins/vibration/ios"
|
:path: ".symlinks/plugins/vibration/ios"
|
||||||
|
wakelock_plus:
|
||||||
|
:path: ".symlinks/plugins/wakelock_plus/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 +179,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
|
||||||
@@ -192,7 +194,8 @@ SPEC CHECKSUMS:
|
|||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||||
|
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -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 = 74;
|
CURRENT_PROJECT_VERSION = 131;
|
||||||
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 = 74;
|
CURRENT_PROJECT_VERSION = 131;
|
||||||
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 = 74;
|
CURRENT_PROJECT_VERSION = 131;
|
||||||
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 = 74;
|
CURRENT_PROJECT_VERSION = 131;
|
||||||
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 = 74;
|
CURRENT_PROJECT_VERSION = 131;
|
||||||
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 = 74;
|
CURRENT_PROJECT_VERSION = 131;
|
||||||
DEVELOPMENT_TEAM = JND55328G8;
|
DEVELOPMENT_TEAM = JND55328G8;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -8,6 +9,7 @@ import Flutter
|
|||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,11 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>74</string>
|
<string>131</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations</string>
|
<string>MeshCore SAR needs Bluetooth to communicate with MeshCore devices for Search & Rescue operations</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -5,22 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000195">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000669">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.405396">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.917785">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="101.387002">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="130.084682">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="209.873722">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="203.665249">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
1699
lib/l10n/app_de.arb
1699
lib/l10n/app_de.arb
File diff suppressed because it is too large
Load Diff
2920
lib/l10n/app_el.arb
2920
lib/l10n/app_el.arb
File diff suppressed because it is too large
Load Diff
2198
lib/l10n/app_en.arb
2198
lib/l10n/app_en.arb
File diff suppressed because it is too large
Load Diff
1688
lib/l10n/app_es.arb
1688
lib/l10n/app_es.arb
File diff suppressed because it is too large
Load Diff
1693
lib/l10n/app_fr.arb
1693
lib/l10n/app_fr.arb
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1693
lib/l10n/app_it.arb
1693
lib/l10n/app_it.arb
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2753
lib/l10n/app_localizations_pl.dart
Normal file
2753
lib/l10n/app_localizations_pl.dart
Normal file
File diff suppressed because it is too large
Load Diff
2764
lib/l10n/app_localizations_pt.dart
Normal file
2764
lib/l10n/app_localizations_pt.dart
Normal file
File diff suppressed because it is too large
Load Diff
2749
lib/l10n/app_localizations_ru.dart
Normal file
2749
lib/l10n/app_localizations_ru.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2737
lib/l10n/app_localizations_tr.dart
Normal file
2737
lib/l10n/app_localizations_tr.dart
Normal file
File diff suppressed because it is too large
Load Diff
2760
lib/l10n/app_localizations_uk.dart
Normal file
2760
lib/l10n/app_localizations_uk.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2579
lib/l10n/app_pl.arb
Normal file
2579
lib/l10n/app_pl.arb
Normal file
File diff suppressed because it is too large
Load Diff
2579
lib/l10n/app_pt.arb
Normal file
2579
lib/l10n/app_pt.arb
Normal file
File diff suppressed because it is too large
Load Diff
814
lib/l10n/app_ru.arb
Normal file
814
lib/l10n/app_ru.arb
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
{
|
||||||
|
"@@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}",
|
||||||
|
"rssi": "RSSI",
|
||||||
|
"snr": "SNR",
|
||||||
|
"ackTimeout": "Таймаут ACK",
|
||||||
|
"opcode": "Опкод",
|
||||||
|
"payload": "Полезная нагрузка",
|
||||||
|
"hops": "Хопы",
|
||||||
|
"hashSize": "Размер хеша",
|
||||||
|
"pathBytes": "Байты пути",
|
||||||
|
"selectedPath": "Выбранный путь",
|
||||||
|
"estimatedTx": "Ожидаемая передача",
|
||||||
|
"senderToReceipt": "От отправителя до получения",
|
||||||
|
"receivedCopies": "Полученные копии",
|
||||||
|
"retryCause": "Причина повтора",
|
||||||
|
"retryMode": "Режим повтора",
|
||||||
|
"retryResult": "Результат повтора",
|
||||||
|
"lastRetry": "Последний повтор",
|
||||||
|
"rxPackets": "RX-пакеты",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Скорость",
|
||||||
|
"window": "Окно",
|
||||||
|
"posttxDelay": "Задержка после передачи",
|
||||||
|
"bandpass": "Полосовой",
|
||||||
|
"bandpassFilterVoice": "Полосовой фильтр голоса",
|
||||||
|
"frequency": "Частота",
|
||||||
|
"australia": "Австралия",
|
||||||
|
"australiaNarrow": "Австралия (Узкий)",
|
||||||
|
"australiaQld": "Австралия: QLD",
|
||||||
|
"australiaSaWa": "Австралия: SA, WA",
|
||||||
|
"newZealand": "Новая Зеландия",
|
||||||
|
"newZealandNarrow": "Новая Зеландия (Узкий)",
|
||||||
|
"switzerland": "Швейцария",
|
||||||
|
"portugal433": "Португалия 433",
|
||||||
|
"portugal868": "Португалия 868",
|
||||||
|
"czechRepublicNarrow": "Чехия (Узкий)",
|
||||||
|
"eu433mhzLongRange": "ЕС 433МГц (Дальний)",
|
||||||
|
"euukDeprecated": "ЕС/Великобритания (Устарело)",
|
||||||
|
"euukNarrow": "ЕС/Великобритания (Узкий)",
|
||||||
|
"usacanadaRecommended": "США/Канада (Рекомендуется)",
|
||||||
|
"vietnamDeprecated": "Вьетнам (Устарело)",
|
||||||
|
"vietnamNarrow": "Вьетнам (Узкий)",
|
||||||
|
"active": "Активно",
|
||||||
|
"addContact": "Добавить контакт",
|
||||||
|
"all": "Все",
|
||||||
|
"autoResolve": "Автоматическое разрешение",
|
||||||
|
"clearAllLabel": "Очистить все",
|
||||||
|
"clearRelays": "Очистить реле",
|
||||||
|
"clearFilters": "Очистить фильтры",
|
||||||
|
"clearRoute": "Очистить маршрут",
|
||||||
|
"clearMessages": "Очистить сообщения",
|
||||||
|
"clearScale": "Очистить масштаб",
|
||||||
|
"clearDiscoveries": "Очистить обнаружения",
|
||||||
|
"clearOnlineTraceDatabase": "Очистить базу трассировок",
|
||||||
|
"clearAllChannels": "Очистить все каналы",
|
||||||
|
"clearAllContacts": "Очистить все контакты",
|
||||||
|
"clearChannels": "Очистить каналы",
|
||||||
|
"clearContacts": "Очистить контакты",
|
||||||
|
"clearPathOnMaxRetry": "Очистить путь при макс. повторе",
|
||||||
|
"create": "Создать",
|
||||||
|
"custom": "Пользовательский",
|
||||||
|
"defaultValue": "По умолчанию",
|
||||||
|
"duplicate": "Дублировать",
|
||||||
|
"editName": "Редактировать имя",
|
||||||
|
"open": "Открыть",
|
||||||
|
"paste": "Вставить",
|
||||||
|
"preview": "Предпросмотр",
|
||||||
|
"remove": "Удалить",
|
||||||
|
"rename": "Переименовать",
|
||||||
|
"resolveAll": "Разрешить все",
|
||||||
|
"send": "Отправить",
|
||||||
|
"sendAnyway": "Отправить все равно",
|
||||||
|
"share": "Поделиться",
|
||||||
|
"shareContact": "Поделиться контактом",
|
||||||
|
"trace": "Трассировка",
|
||||||
|
"use": "Использовать",
|
||||||
|
"useSelectedFrequency": "Использовать выбранную частоту",
|
||||||
|
"discovery": "Обнаружение",
|
||||||
|
"discoverRepeaters": "Обнаружить повторители",
|
||||||
|
"discoverSensors": "Обнаружить датчики",
|
||||||
|
"repeaterDiscoverySent": "Обнаружение повторителей отправлено",
|
||||||
|
"sensorDiscoverySent": "Обнаружение датчиков отправлено",
|
||||||
|
"clearedPendingDiscoveries": "Ожидающие обнаружения очищены.",
|
||||||
|
"autoDiscovery": "Автоматическое обнаружение",
|
||||||
|
"enableAutomaticAdding": "Включить автоматическое добавление",
|
||||||
|
"autoaddRepeaters": "Автодобавление повторителей",
|
||||||
|
"autoaddRoomServers": "Автодобавление серверов комнат",
|
||||||
|
"autoaddSensors": "Автодобавление датчиков",
|
||||||
|
"autoaddUsers": "Автодобавление пользователей",
|
||||||
|
"overwriteOldestWhenFull": "Перезаписать старейшие при заполнении",
|
||||||
|
"storage": "Хранилище",
|
||||||
|
"dangerZone": "Опасная зона",
|
||||||
|
"profiles": "Профили",
|
||||||
|
"favourites": "Избранное",
|
||||||
|
"sensors": "Датчики",
|
||||||
|
"others": "Другие",
|
||||||
|
"gpsModule": "Модуль GPS",
|
||||||
|
"liveTraffic": "Живой трафик",
|
||||||
|
"repeatersMap": "Карта повторителей",
|
||||||
|
"spectrumScan": "Сканирование спектра",
|
||||||
|
"blePacketLogs": "Журналы BLE-пакетов",
|
||||||
|
"onlineTraceDatabase": "База трассировок",
|
||||||
|
"routePathByteSize": "Размер пути в байтах",
|
||||||
|
"messageNotifications": "Уведомления о сообщениях",
|
||||||
|
"sarAlerts": "SAR-оповещения",
|
||||||
|
"discoveryNotifications": "Уведомления об обнаружении",
|
||||||
|
"updateNotifications": "Уведомления об обновлениях",
|
||||||
|
"muteWhileAppIsOpen": "Без звука при открытом приложении",
|
||||||
|
"disableContacts": "Отключить контакты",
|
||||||
|
"enableSensorsTab": "Включить вкладку Датчики",
|
||||||
|
"enableProfiles": "Включить профили",
|
||||||
|
"autoRouteRotation": "Автоматическая ротация маршрута",
|
||||||
|
"nearestRepeaterFallback": "Ближайший повторитель как резерв",
|
||||||
|
"deleteAllStoredMessageHistory": "Удалить всю историю сообщений",
|
||||||
|
"messageFontSize": "Размер шрифта сообщений",
|
||||||
|
"rotateMapWithHeading": "Вращать карту по направлению",
|
||||||
|
"showMapDebugInfo": "Показать отладочную информацию карты",
|
||||||
|
"openMapInFullscreen": "Открыть карту на весь экран",
|
||||||
|
"showSarMarkersLabel": "Показать SAR-маркеры",
|
||||||
|
"displaySarMarkersOnTheMainMap": "Отображать SAR-маркеры на основной карте",
|
||||||
|
"showAllContactTrailsLabel": "Показать все следы контактов",
|
||||||
|
"hideRepeatersOnMap": "Скрыть повторители на карте",
|
||||||
|
"setMapScale": "Установить масштаб карты",
|
||||||
|
"customMapScaleSaved": "Пользовательский масштаб карты сохранён",
|
||||||
|
"voiceBitrate": "Битрейт голоса",
|
||||||
|
"voiceCompressor": "Компрессор голоса",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Выравнивает тихую и громкую речь",
|
||||||
|
"voiceLimiter": "Лимитер голоса",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Предотвращает обрезку пиков перед кодированием",
|
||||||
|
"micAutoGain": "Автоусиление микрофона",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Позволяет рекордеру регулировать уровень входа",
|
||||||
|
"echoCancellation": "Подавление эха",
|
||||||
|
"noiseSuppression": "Подавление шума",
|
||||||
|
"trimSilenceInVoiceMessages": "Обрезать тишину в голосовых сообщениях",
|
||||||
|
"compressor": "Компрессор",
|
||||||
|
"limiter": "Лимитер",
|
||||||
|
"autoGain": "Автоусиление",
|
||||||
|
"echoCancel": "Эхо",
|
||||||
|
"noiseSuppress": "Шум",
|
||||||
|
"silenceTrim": "Тишина",
|
||||||
|
"maxImageSize": "Максимальный размер изображения",
|
||||||
|
"imageCompression": "Сжатие изображения",
|
||||||
|
"grayscale": "Оттенки серого",
|
||||||
|
"ultraMode": "Режим ультра",
|
||||||
|
"fastPrivateGpsUpdates": "Быстрые приватные GPS-обновления",
|
||||||
|
"movementThreshold": "Порог движения",
|
||||||
|
"fastGpsMovementThreshold": "Порог движения быстрого GPS",
|
||||||
|
"fastGpsActiveuseInterval": "Интервал активного использования быстрого GPS",
|
||||||
|
"activeuseUpdateInterval": "Интервал обновления при активном использовании",
|
||||||
|
"repeatNearbyTraffic": "Повторять ближний трафик",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Ретранслировать через повторители по сети",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Только рядом, без flood повторителей",
|
||||||
|
"multihop": "Многохоповый",
|
||||||
|
"createProfile": "Создать профиль",
|
||||||
|
"renameProfile": "Переименовать профиль",
|
||||||
|
"newProfile": "Новый профиль",
|
||||||
|
"manageProfiles": "Управление профилями",
|
||||||
|
"enableProfilesToStartManagingThem": "Включите профили, чтобы начать управлять ими.",
|
||||||
|
"openMessage": "Открыть сообщение",
|
||||||
|
"jumpToTheRelatedSarMessage": "Перейти к связанному SAR-сообщению",
|
||||||
|
"removeSarMarker": "Удалить SAR-маркер",
|
||||||
|
"pleaseSelectADestinationToSendSarMarker": "Выберите назначение для отправки SAR-маркера",
|
||||||
|
"sarMarkerBroadcastToPublicChannel": "SAR-маркер отправлен в публичный канал",
|
||||||
|
"sarMarkerSentToRoom": "SAR-маркер отправлен в комнату",
|
||||||
|
"loadFromGallery": "Загрузить из галереи",
|
||||||
|
"replaceImage": "Заменить изображение",
|
||||||
|
"selectFromGallery": "Выбрать из галереи",
|
||||||
|
"team": "Команда",
|
||||||
|
"found": "Найдено",
|
||||||
|
"staging": "Место сбора",
|
||||||
|
"object": "Объект",
|
||||||
|
"quiet": "Тихо",
|
||||||
|
"moderate": "Умеренно",
|
||||||
|
"busy": "Занято",
|
||||||
|
"spectrumScanReturnedNoCandidateFrequencies": "Сканирование спектра не нашло частот-кандидатов",
|
||||||
|
"searchMessages": "Поиск сообщений",
|
||||||
|
"sendImageFromGallery": "Отправить изображение из галереи",
|
||||||
|
"takePhoto": "Сделать фото",
|
||||||
|
"dmOnly": "Только личные сообщения",
|
||||||
|
"allMessages": "Все сообщения",
|
||||||
|
"sendToPublicChannel": "Отправить в публичный канал?",
|
||||||
|
"selectMarkerTypeAndDestination": "Выберите тип маркера и назначение",
|
||||||
|
"noDestinationsAvailableLabel": "Нет доступных назначений",
|
||||||
|
"image": "Изображение",
|
||||||
|
"format": "Формат",
|
||||||
|
"dimensions": "Размеры",
|
||||||
|
"segments": "Сегменты",
|
||||||
|
"transfers": "Передачи",
|
||||||
|
"downloadedBy": "Загружено",
|
||||||
|
"saveDiscoverySettings": "Сохранить настройки обнаружения",
|
||||||
|
"savePublicInfo": "Сохранить публичную информацию",
|
||||||
|
"saveRadioSettings": "Сохранить настройки радио",
|
||||||
|
"savePath": "Сохранить путь",
|
||||||
|
"wipeDeviceData": "Стереть данные устройства",
|
||||||
|
"wipeDevice": "Стереть устройство",
|
||||||
|
"destructiveDeviceActions": "Деструктивные действия устройства.",
|
||||||
|
"chooseAPresetOrFinetuneCustomRadioSettings": "Выберите пресет или настройте радио вручную.",
|
||||||
|
"chooseTheNameAndLocationThisDeviceShares": "Выберите имя и местоположение, которыми делится устройство.",
|
||||||
|
"availableSpaceOnThisDevice": "Доступное место на этом устройстве.",
|
||||||
|
"used": "Использовано",
|
||||||
|
"total": "Всего",
|
||||||
|
"renameValue": "Переименовать значение",
|
||||||
|
"customizeFields": "Настроить поля",
|
||||||
|
"livePreview": "Предпросмотр в реальном времени",
|
||||||
|
"refreshSchedule": "Расписание обновления",
|
||||||
|
"noResponse": "Нет ответа",
|
||||||
|
"refreshing": "Обновление",
|
||||||
|
"unavailable": "Недоступно",
|
||||||
|
"pickARelayOrNodeToWatchInSensors": "Выберите реле или узел для наблюдения.",
|
||||||
|
"publicKeyLabel": "Публичный ключ",
|
||||||
|
"alreadyInContacts": "Уже в контактах",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Подключитесь к устройству перед добавлением контактов",
|
||||||
|
"fromContacts": "Из контактов",
|
||||||
|
"onlineOnly": "Только онлайн",
|
||||||
|
"inBoth": "В обоих",
|
||||||
|
"source": "Источник",
|
||||||
|
"manualRouteEdit": "Ручное редактирование маршрута",
|
||||||
|
"observedMeshRoute": "Наблюдаемый mesh-маршрут",
|
||||||
|
"allMessagesCleared": "Все сообщения очищены",
|
||||||
|
"onlineTraceDatabaseCleared": "База трассировок очищена",
|
||||||
|
"packetLogsCleared": "Журналы пакетов очищены",
|
||||||
|
"hexDataCopiedToClipboard": "Hex-данные скопированы в буфер",
|
||||||
|
"developerModeEnabled": "Режим разработчика включён",
|
||||||
|
"developerModeDisabled": "Режим разработчика отключён",
|
||||||
|
"clipboardIsEmpty": "Буфер обмена пуст",
|
||||||
|
"contactImported": "Контакт импортирован",
|
||||||
|
"contactLinkCopiedToClipboard": "Ссылка на контакт скопирована в буфер",
|
||||||
|
"failedToExportContact": "Не удалось экспортировать контакт",
|
||||||
|
"noLogsToExport": "Нет журналов для экспорта",
|
||||||
|
"exportAsCsv": "Экспортировать как CSV",
|
||||||
|
"exportAsText": "Экспортировать как текст",
|
||||||
|
"receivedRfc3339": "Получено (RFC3339)",
|
||||||
|
"buildTime": "Время сборки",
|
||||||
|
"downloadUrlNotAvailable": "URL загрузки недоступен",
|
||||||
|
"cannotOpenDownloadUrl": "Не удаётся открыть URL загрузки",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "Проверка обновлений доступна только на Android",
|
||||||
|
"youAreRunningTheLatestVersion": "Вы используете последнюю версию",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "Обновление доступно, но URL загрузки не найден",
|
||||||
|
"startTictactoe": "Начать Tic-Tac-Toe",
|
||||||
|
"tictactoeUnavailable": "Tic-Tac-Toe недоступно",
|
||||||
|
"tictactoeOpponentUnknown": "Tic-Tac-Toe: противник неизвестен",
|
||||||
|
"tictactoeWaitingForStart": "Tic-Tac-Toe: ожидание начала",
|
||||||
|
"acceptsShareLinks": "Принимает общие ссылки",
|
||||||
|
"supportsRawHex": "Поддерживает raw hex",
|
||||||
|
"clipboardfriendly": "Удобно для буфера",
|
||||||
|
"captured": "Захвачено",
|
||||||
|
"size": "Размер",
|
||||||
|
"noCustomChannelsToClear": "Нет пользовательских каналов для очистки.",
|
||||||
|
"noDeviceContactsToClear": "Нет контактов устройства для очистки."
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
2579
lib/l10n/app_tr.arb
Normal file
2579
lib/l10n/app_tr.arb
Normal file
File diff suppressed because it is too large
Load Diff
2579
lib/l10n/app_uk.arb
Normal file
2579
lib/l10n/app_uk.arb
Normal file
File diff suppressed because it is too large
Load Diff
2567
lib/l10n/app_zh.arb
2567
lib/l10n/app_zh.arb
File diff suppressed because it is too large
Load Diff
@@ -1 +1,287 @@
|
|||||||
{}
|
{
|
||||||
|
"de": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"el": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"es": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"fr": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"hr": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"it": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"pl": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"pt": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ru": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"sl": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"tr": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"uk": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"zh": [
|
||||||
|
"autoSend",
|
||||||
|
"autoSendDescription",
|
||||||
|
"sendDirect",
|
||||||
|
"sendDirectDescription",
|
||||||
|
"sendFlood",
|
||||||
|
"sendFloodDescription",
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
113
lib/main.dart
113
lib/main.dart
@@ -1,10 +1,14 @@
|
|||||||
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';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
import 'providers/connection_provider.dart';
|
import 'providers/connection_provider.dart';
|
||||||
import 'providers/contacts_provider.dart';
|
import 'providers/contacts_provider.dart';
|
||||||
import 'providers/messages_provider.dart';
|
import 'providers/messages_provider.dart';
|
||||||
@@ -14,19 +18,27 @@ 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/offline_tiles_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/profile_manager.dart';
|
||||||
|
import 'services/profile_workspace_coordinator.dart';
|
||||||
|
import 'services/profiles_feature_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/discovery_screen.dart';
|
||||||
import 'screens/home_screen.dart';
|
import 'screens/home_screen.dart';
|
||||||
import 'screens/welcome_wizard_screen.dart';
|
import 'screens/welcome_wizard_screen.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
|
|
||||||
void main() {
|
Future<void> main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await WakelockPlus.enable();
|
||||||
runApp(const MeshCoreSarApp());
|
runApp(const MeshCoreSarApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +50,13 @@ class MeshCoreSarApp extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
||||||
|
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
||||||
AppThemeMode _themeMode = AppThemeMode.system;
|
AppThemeMode _themeMode = AppThemeMode.system;
|
||||||
Locale? _locale;
|
Locale? _locale;
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
bool _shouldShowPermissionDialog = false;
|
bool _shouldShowPermissionDialog = false;
|
||||||
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
bool _wizardCompleted = true; // Will be updated in _initializeApp()
|
||||||
|
String? _pendingNotificationPayload;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -51,6 +65,14 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initializeApp() async {
|
Future<void> _initializeApp() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final profilesEnabled = await ProfilesFeatureService.isEnabled();
|
||||||
|
final activeProfileId =
|
||||||
|
prefs.getString(ProfileManager.activeProfileIdKey) ?? 'default';
|
||||||
|
await ProfileStorageScope.bootstrap(
|
||||||
|
profilesEnabled: profilesEnabled,
|
||||||
|
activeProfileId: activeProfileId,
|
||||||
|
);
|
||||||
await _loadThemePreference();
|
await _loadThemePreference();
|
||||||
await _loadLocalePreference();
|
await _loadLocalePreference();
|
||||||
|
|
||||||
@@ -62,6 +84,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
// Set up notification tap handler for update notifications
|
// Set up notification tap handler for update notifications
|
||||||
NotificationService().onNotificationTapped = _handleNotificationTap;
|
NotificationService().onNotificationTapped = _handleNotificationTap;
|
||||||
|
_pendingNotificationPayload = NotificationService().consumeLaunchPayload();
|
||||||
|
|
||||||
// Check if we need to request location permissions
|
// Check if we need to request location permissions
|
||||||
await _checkLocationPermissions();
|
await _checkLocationPermissions();
|
||||||
@@ -70,10 +93,21 @@ 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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (_pendingNotificationPayload != null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final payload = _pendingNotificationPayload;
|
||||||
|
_pendingNotificationPayload = null;
|
||||||
|
_handleNotificationTap(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle notification tap
|
/// Handle notification tap
|
||||||
@@ -82,10 +116,27 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
|
|
||||||
debugPrint('[Main] Notification tapped: $payload');
|
debugPrint('[Main] Notification tapped: $payload');
|
||||||
|
|
||||||
|
if (!_wizardCompleted) {
|
||||||
|
_pendingNotificationPayload = payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle update notification tap
|
// Handle update notification tap
|
||||||
if (payload.startsWith('update:')) {
|
if (payload.startsWith('update:')) {
|
||||||
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
final downloadUrl = payload.substring(7); // Remove 'update:' prefix
|
||||||
_launchUpdateDownload(downloadUrl);
|
_launchUpdateDownload(downloadUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.startsWith('discovery:')) {
|
||||||
|
final navigator = _navigatorKey.currentState;
|
||||||
|
if (navigator == null) {
|
||||||
|
_pendingNotificationPayload = payload;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(builder: (context) => const DiscoveryScreen()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// SAR and message notifications handled by their respective providers
|
// SAR and message notifications handled by their respective providers
|
||||||
}
|
}
|
||||||
@@ -158,7 +209,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 +285,15 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => OfflineTilesProvider()),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (_) {
|
||||||
|
final manager = ProfileManager();
|
||||||
|
manager.initialize();
|
||||||
|
return manager;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// Voice provider (packet reassembly + playback)
|
// Voice provider (packet reassembly + playback)
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
@@ -246,18 +306,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 +324,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 +333,6 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
messages,
|
messages,
|
||||||
drawings,
|
drawings,
|
||||||
channels,
|
channels,
|
||||||
tileCache,
|
|
||||||
previous,
|
previous,
|
||||||
) =>
|
) =>
|
||||||
previous ??
|
previous ??
|
||||||
@@ -290,9 +344,41 @@ 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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ProxyProvider6<
|
||||||
|
ProfileManager,
|
||||||
|
AppProvider,
|
||||||
|
ConnectionProvider,
|
||||||
|
ContactsProvider,
|
||||||
|
MessagesProvider,
|
||||||
|
MapProvider,
|
||||||
|
ProfileWorkspaceCoordinator
|
||||||
|
>(
|
||||||
|
update:
|
||||||
|
(
|
||||||
|
context,
|
||||||
|
profileManager,
|
||||||
|
appProvider,
|
||||||
|
connectionProvider,
|
||||||
|
contactsProvider,
|
||||||
|
messagesProvider,
|
||||||
|
mapProvider,
|
||||||
|
previous,
|
||||||
|
) => ProfileWorkspaceCoordinator(
|
||||||
|
profileManager: profileManager,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
messagesProvider: messagesProvider,
|
||||||
|
sensorsProvider: context.read<SensorsProvider>(),
|
||||||
|
mapProvider: mapProvider,
|
||||||
|
drawingProvider: context.read<DrawingProvider>(),
|
||||||
|
channelsProvider: context.read<ChannelsProvider>(),
|
||||||
|
voiceProvider: context.read<VoiceProvider>(),
|
||||||
|
imageProvider: context.read<ip.ImageProvider>(),
|
||||||
|
appProvider: appProvider,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: _buildMaterialApp(),
|
child: _buildMaterialApp(),
|
||||||
);
|
);
|
||||||
@@ -303,6 +389,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
final systemBrightness = MediaQuery.platformBrightnessOf(context);
|
||||||
final materialApp = MaterialApp(
|
final materialApp = MaterialApp(
|
||||||
|
navigatorKey: _navigatorKey,
|
||||||
key: ValueKey<String?>(
|
key: ValueKey<String?>(
|
||||||
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
|
||||||
),
|
),
|
||||||
@@ -330,7 +417,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,
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ class Channel {
|
|||||||
} else {
|
} else {
|
||||||
// Normal channel: require explicit secret
|
// Normal channel: require explicit secret
|
||||||
if (explicitSecret == null || explicitSecret.length != 16) {
|
if (explicitSecret == null || explicitSecret.length != 16) {
|
||||||
throw ArgumentError(
|
throw ArgumentError('Normal channels require a 16-byte secret');
|
||||||
'Normal channels require a 16-byte secret',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Channel(
|
return Channel(
|
||||||
index: index,
|
index: index,
|
||||||
@@ -78,6 +76,18 @@ class Channel {
|
|||||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool isHashChannelName(String channelName) {
|
||||||
|
return channelName.trim().startsWith('#');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String pskBase64ForHashChannelName(String channelName) {
|
||||||
|
final normalized = channelName.trim();
|
||||||
|
if (!isHashChannelName(normalized)) {
|
||||||
|
throw ArgumentError('Only #channels can export derived psk_base64');
|
||||||
|
}
|
||||||
|
return base64.encode(_generateHashChannelSecret(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
/// Create the default public channel (channel 0)
|
/// Create the default public channel (channel 0)
|
||||||
/// Uses the well-known pre-shared key from MeshCore
|
/// Uses the well-known pre-shared key from MeshCore
|
||||||
factory Channel.publicChannel() {
|
factory Channel.publicChannel() {
|
||||||
@@ -85,8 +95,22 @@ class Channel {
|
|||||||
index: 0,
|
index: 0,
|
||||||
name: 'Public Channel',
|
name: 'Public Channel',
|
||||||
secret: Uint8List.fromList([
|
secret: Uint8List.fromList([
|
||||||
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
|
0x8b,
|
||||||
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
|
0x33,
|
||||||
|
0x87,
|
||||||
|
0xe9,
|
||||||
|
0xc5,
|
||||||
|
0xcd,
|
||||||
|
0xea,
|
||||||
|
0x6a,
|
||||||
|
0xc9,
|
||||||
|
0xe5,
|
||||||
|
0xed,
|
||||||
|
0xba,
|
||||||
|
0xa1,
|
||||||
|
0x15,
|
||||||
|
0xcd,
|
||||||
|
0x72,
|
||||||
]),
|
]),
|
||||||
flags: null,
|
flags: null,
|
||||||
);
|
);
|
||||||
@@ -95,6 +119,18 @@ class Channel {
|
|||||||
/// Check if this is a hash-based channel (name starts with '#')
|
/// Check if this is a hash-based channel (name starts with '#')
|
||||||
bool get isHashChannel => name.startsWith('#');
|
bool get isHashChannel => name.startsWith('#');
|
||||||
|
|
||||||
|
/// Base64-encoded PSK for sharing with firmware CLI and related tooling.
|
||||||
|
String get pskBase64 => base64.encode(secret);
|
||||||
|
|
||||||
|
/// MeshCore group packets carry the first byte of SHA256(channel secret).
|
||||||
|
int get hashByte {
|
||||||
|
final digest = sha256.convert(secret);
|
||||||
|
return digest.bytes.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
String get hashHex =>
|
||||||
|
hashByte.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||||
|
|
||||||
/// Display name for the channel
|
/// Display name for the channel
|
||||||
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
|
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
|
||||||
String get displayName {
|
String get displayName {
|
||||||
@@ -131,12 +167,7 @@ class Channel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a copy with modified fields
|
/// Create a copy with modified fields
|
||||||
Channel copyWith({
|
Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
|
||||||
int? index,
|
|
||||||
String? name,
|
|
||||||
Uint8List? secret,
|
|
||||||
int? flags,
|
|
||||||
}) {
|
|
||||||
return Channel(
|
return Channel(
|
||||||
index: index ?? this.index,
|
index: index ?? this.index,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
|
|||||||
518
lib/models/config_profile.dart
Normal file
518
lib/models/config_profile.dart
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'channel.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class DeviceConfigProfileSection {
|
||||||
|
final int? frequencyKhz;
|
||||||
|
final int? bandwidth;
|
||||||
|
final int? spreadingFactor;
|
||||||
|
final int? codingRate;
|
||||||
|
final bool? repeatEnabled;
|
||||||
|
final int? txPower;
|
||||||
|
final int? telemetryModes;
|
||||||
|
final int? advertLocationPolicy;
|
||||||
|
final int? multiAcks;
|
||||||
|
final bool? manualAddContacts;
|
||||||
|
final bool? autoAddUsers;
|
||||||
|
final bool? autoAddRepeaters;
|
||||||
|
final bool? autoAddRoomServers;
|
||||||
|
final bool? autoAddSensors;
|
||||||
|
final bool? autoAddOverwriteOldest;
|
||||||
|
final double? publicLatitude;
|
||||||
|
final double? publicLongitude;
|
||||||
|
|
||||||
|
const DeviceConfigProfileSection({
|
||||||
|
this.frequencyKhz,
|
||||||
|
this.bandwidth,
|
||||||
|
this.spreadingFactor,
|
||||||
|
this.codingRate,
|
||||||
|
this.repeatEnabled,
|
||||||
|
this.txPower,
|
||||||
|
this.telemetryModes,
|
||||||
|
this.advertLocationPolicy,
|
||||||
|
this.multiAcks,
|
||||||
|
this.manualAddContacts,
|
||||||
|
this.autoAddUsers,
|
||||||
|
this.autoAddRepeaters,
|
||||||
|
this.autoAddRoomServers,
|
||||||
|
this.autoAddSensors,
|
||||||
|
this.autoAddOverwriteOldest,
|
||||||
|
this.publicLatitude,
|
||||||
|
this.publicLongitude,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isEmpty =>
|
||||||
|
frequencyKhz == null &&
|
||||||
|
bandwidth == null &&
|
||||||
|
spreadingFactor == null &&
|
||||||
|
codingRate == null &&
|
||||||
|
repeatEnabled == null &&
|
||||||
|
txPower == null &&
|
||||||
|
telemetryModes == null &&
|
||||||
|
advertLocationPolicy == null &&
|
||||||
|
multiAcks == null &&
|
||||||
|
manualAddContacts == null &&
|
||||||
|
autoAddUsers == null &&
|
||||||
|
autoAddRepeaters == null &&
|
||||||
|
autoAddRoomServers == null &&
|
||||||
|
autoAddSensors == null &&
|
||||||
|
autoAddOverwriteOldest == null &&
|
||||||
|
publicLatitude == null &&
|
||||||
|
publicLongitude == null;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'frequencyKhz': frequencyKhz,
|
||||||
|
'bandwidth': bandwidth,
|
||||||
|
'spreadingFactor': spreadingFactor,
|
||||||
|
'codingRate': codingRate,
|
||||||
|
'repeatEnabled': repeatEnabled,
|
||||||
|
'txPower': txPower,
|
||||||
|
'telemetryModes': telemetryModes,
|
||||||
|
'advertLocationPolicy': advertLocationPolicy,
|
||||||
|
'multiAcks': multiAcks,
|
||||||
|
'manualAddContacts': manualAddContacts,
|
||||||
|
'autoAddUsers': autoAddUsers,
|
||||||
|
'autoAddRepeaters': autoAddRepeaters,
|
||||||
|
'autoAddRoomServers': autoAddRoomServers,
|
||||||
|
'autoAddSensors': autoAddSensors,
|
||||||
|
'autoAddOverwriteOldest': autoAddOverwriteOldest,
|
||||||
|
'publicLatitude': publicLatitude,
|
||||||
|
'publicLongitude': publicLongitude,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory DeviceConfigProfileSection.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DeviceConfigProfileSection(
|
||||||
|
frequencyKhz: json['frequencyKhz'] as int?,
|
||||||
|
bandwidth: json['bandwidth'] as int?,
|
||||||
|
spreadingFactor: json['spreadingFactor'] as int?,
|
||||||
|
codingRate: json['codingRate'] as int?,
|
||||||
|
repeatEnabled: json['repeatEnabled'] as bool?,
|
||||||
|
txPower: json['txPower'] as int?,
|
||||||
|
telemetryModes: json['telemetryModes'] as int?,
|
||||||
|
advertLocationPolicy: json['advertLocationPolicy'] as int?,
|
||||||
|
multiAcks: json['multiAcks'] as int?,
|
||||||
|
manualAddContacts: json['manualAddContacts'] as bool?,
|
||||||
|
autoAddUsers: json['autoAddUsers'] as bool?,
|
||||||
|
autoAddRepeaters: json['autoAddRepeaters'] as bool?,
|
||||||
|
autoAddRoomServers: json['autoAddRoomServers'] as bool?,
|
||||||
|
autoAddSensors: json['autoAddSensors'] as bool?,
|
||||||
|
autoAddOverwriteOldest: json['autoAddOverwriteOldest'] as bool?,
|
||||||
|
publicLatitude: (json['publicLatitude'] as num?)?.toDouble(),
|
||||||
|
publicLongitude: (json['publicLongitude'] as num?)?.toDouble(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class AppSettingsProfileSection {
|
||||||
|
final bool? mapEnabled;
|
||||||
|
final bool? contactsEnabled;
|
||||||
|
final bool? sensorsEnabled;
|
||||||
|
final bool? voiceSilenceTrimmingEnabled;
|
||||||
|
final bool? voiceBandPassFilterEnabled;
|
||||||
|
final bool? voiceCompressorEnabled;
|
||||||
|
final bool? voiceLimiterEnabled;
|
||||||
|
final bool? voiceAutoGainEnabled;
|
||||||
|
final bool? voiceEchoCancellationEnabled;
|
||||||
|
final bool? voiceNoiseSuppressionEnabled;
|
||||||
|
final double? messageFontScale;
|
||||||
|
final bool? clearPathOnMaxRetry;
|
||||||
|
final bool? nearestRelayFallbackEnabled;
|
||||||
|
final int? voiceBitrate;
|
||||||
|
final int? routeHashSize;
|
||||||
|
final int? imageMaxSize;
|
||||||
|
final int? imageCompression;
|
||||||
|
final bool? imageGrayscale;
|
||||||
|
final bool? imageUltraMode;
|
||||||
|
final bool? showRxTxIndicators;
|
||||||
|
final bool? fastLocationUpdatesEnabled;
|
||||||
|
final double? fastLocationMovementThresholdMeters;
|
||||||
|
final int? fastLocationActiveCadenceSeconds;
|
||||||
|
|
||||||
|
const AppSettingsProfileSection({
|
||||||
|
this.mapEnabled,
|
||||||
|
this.contactsEnabled,
|
||||||
|
this.sensorsEnabled,
|
||||||
|
this.voiceSilenceTrimmingEnabled,
|
||||||
|
this.voiceBandPassFilterEnabled,
|
||||||
|
this.voiceCompressorEnabled,
|
||||||
|
this.voiceLimiterEnabled,
|
||||||
|
this.voiceAutoGainEnabled,
|
||||||
|
this.voiceEchoCancellationEnabled,
|
||||||
|
this.voiceNoiseSuppressionEnabled,
|
||||||
|
this.messageFontScale,
|
||||||
|
this.clearPathOnMaxRetry,
|
||||||
|
this.nearestRelayFallbackEnabled,
|
||||||
|
this.voiceBitrate,
|
||||||
|
this.routeHashSize,
|
||||||
|
this.imageMaxSize,
|
||||||
|
this.imageCompression,
|
||||||
|
this.imageGrayscale,
|
||||||
|
this.imageUltraMode,
|
||||||
|
this.showRxTxIndicators,
|
||||||
|
this.fastLocationUpdatesEnabled,
|
||||||
|
this.fastLocationMovementThresholdMeters,
|
||||||
|
this.fastLocationActiveCadenceSeconds,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isEmpty =>
|
||||||
|
mapEnabled == null &&
|
||||||
|
contactsEnabled == null &&
|
||||||
|
sensorsEnabled == null &&
|
||||||
|
voiceSilenceTrimmingEnabled == null &&
|
||||||
|
voiceBandPassFilterEnabled == null &&
|
||||||
|
voiceCompressorEnabled == null &&
|
||||||
|
voiceLimiterEnabled == null &&
|
||||||
|
voiceAutoGainEnabled == null &&
|
||||||
|
voiceEchoCancellationEnabled == null &&
|
||||||
|
voiceNoiseSuppressionEnabled == null &&
|
||||||
|
messageFontScale == null &&
|
||||||
|
clearPathOnMaxRetry == null &&
|
||||||
|
nearestRelayFallbackEnabled == null &&
|
||||||
|
voiceBitrate == null &&
|
||||||
|
routeHashSize == null &&
|
||||||
|
imageMaxSize == null &&
|
||||||
|
imageCompression == null &&
|
||||||
|
imageGrayscale == null &&
|
||||||
|
imageUltraMode == null &&
|
||||||
|
showRxTxIndicators == null &&
|
||||||
|
fastLocationUpdatesEnabled == null &&
|
||||||
|
fastLocationMovementThresholdMeters == null &&
|
||||||
|
fastLocationActiveCadenceSeconds == null;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'mapEnabled': mapEnabled,
|
||||||
|
'contactsEnabled': contactsEnabled,
|
||||||
|
'sensorsEnabled': sensorsEnabled,
|
||||||
|
'voiceSilenceTrimmingEnabled': voiceSilenceTrimmingEnabled,
|
||||||
|
'voiceBandPassFilterEnabled': voiceBandPassFilterEnabled,
|
||||||
|
'voiceCompressorEnabled': voiceCompressorEnabled,
|
||||||
|
'voiceLimiterEnabled': voiceLimiterEnabled,
|
||||||
|
'voiceAutoGainEnabled': voiceAutoGainEnabled,
|
||||||
|
'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled,
|
||||||
|
'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled,
|
||||||
|
'messageFontScale': messageFontScale,
|
||||||
|
'clearPathOnMaxRetry': clearPathOnMaxRetry,
|
||||||
|
'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled,
|
||||||
|
'voiceBitrate': voiceBitrate,
|
||||||
|
'routeHashSize': routeHashSize,
|
||||||
|
'imageMaxSize': imageMaxSize,
|
||||||
|
'imageCompression': imageCompression,
|
||||||
|
'imageGrayscale': imageGrayscale,
|
||||||
|
'imageUltraMode': imageUltraMode,
|
||||||
|
'showRxTxIndicators': showRxTxIndicators,
|
||||||
|
'fastLocationUpdatesEnabled': fastLocationUpdatesEnabled,
|
||||||
|
'fastLocationMovementThresholdMeters': fastLocationMovementThresholdMeters,
|
||||||
|
'fastLocationActiveCadenceSeconds': fastLocationActiveCadenceSeconds,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory AppSettingsProfileSection.fromJson(Map<String, dynamic> json) {
|
||||||
|
return AppSettingsProfileSection(
|
||||||
|
mapEnabled: json['mapEnabled'] as bool?,
|
||||||
|
contactsEnabled: json['contactsEnabled'] as bool?,
|
||||||
|
sensorsEnabled: json['sensorsEnabled'] as bool?,
|
||||||
|
voiceSilenceTrimmingEnabled: json['voiceSilenceTrimmingEnabled'] as bool?,
|
||||||
|
voiceBandPassFilterEnabled: json['voiceBandPassFilterEnabled'] as bool?,
|
||||||
|
voiceCompressorEnabled: json['voiceCompressorEnabled'] as bool?,
|
||||||
|
voiceLimiterEnabled: json['voiceLimiterEnabled'] as bool?,
|
||||||
|
voiceAutoGainEnabled: json['voiceAutoGainEnabled'] as bool?,
|
||||||
|
voiceEchoCancellationEnabled:
|
||||||
|
json['voiceEchoCancellationEnabled'] as bool?,
|
||||||
|
voiceNoiseSuppressionEnabled:
|
||||||
|
json['voiceNoiseSuppressionEnabled'] as bool?,
|
||||||
|
messageFontScale: (json['messageFontScale'] as num?)?.toDouble(),
|
||||||
|
clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?,
|
||||||
|
nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?,
|
||||||
|
voiceBitrate: json['voiceBitrate'] as int?,
|
||||||
|
routeHashSize: json['routeHashSize'] as int?,
|
||||||
|
imageMaxSize: json['imageMaxSize'] as int?,
|
||||||
|
imageCompression: json['imageCompression'] as int?,
|
||||||
|
imageGrayscale: json['imageGrayscale'] as bool?,
|
||||||
|
imageUltraMode: json['imageUltraMode'] as bool?,
|
||||||
|
showRxTxIndicators: json['showRxTxIndicators'] as bool?,
|
||||||
|
fastLocationUpdatesEnabled: json['fastLocationUpdatesEnabled'] as bool?,
|
||||||
|
fastLocationMovementThresholdMeters:
|
||||||
|
(json['fastLocationMovementThresholdMeters'] as num?)?.toDouble(),
|
||||||
|
fastLocationActiveCadenceSeconds:
|
||||||
|
json['fastLocationActiveCadenceSeconds'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class MapWorkspaceProfileSection {
|
||||||
|
final Map<String, dynamic>? mapPrefs;
|
||||||
|
final List<Map<String, dynamic>> drawings;
|
||||||
|
final Map<String, dynamic>? currentTrail;
|
||||||
|
final List<Map<String, dynamic>> trailHistory;
|
||||||
|
final Map<String, dynamic>? importedTrail;
|
||||||
|
final bool? isTrailVisible;
|
||||||
|
final bool? showCadastralOverlay;
|
||||||
|
final bool? showForestRoadsOverlay;
|
||||||
|
final bool? showHikingTrailsOverlay;
|
||||||
|
final bool? showMainRoadsOverlay;
|
||||||
|
final bool? showHouseNumbersOverlay;
|
||||||
|
final bool? showFireHazardZonesOverlay;
|
||||||
|
final bool? showHistoricalFiresOverlay;
|
||||||
|
final bool? showFirebreaksOverlay;
|
||||||
|
final bool? showKrasFireZonesOverlay;
|
||||||
|
final bool? showPlaceNamesOverlay;
|
||||||
|
final bool? showMunicipalityBordersOverlay;
|
||||||
|
final bool? hideRepeatersOnMap;
|
||||||
|
|
||||||
|
const MapWorkspaceProfileSection({
|
||||||
|
this.mapPrefs,
|
||||||
|
this.drawings = const [],
|
||||||
|
this.currentTrail,
|
||||||
|
this.trailHistory = const [],
|
||||||
|
this.importedTrail,
|
||||||
|
this.isTrailVisible,
|
||||||
|
this.showCadastralOverlay,
|
||||||
|
this.showForestRoadsOverlay,
|
||||||
|
this.showHikingTrailsOverlay,
|
||||||
|
this.showMainRoadsOverlay,
|
||||||
|
this.showHouseNumbersOverlay,
|
||||||
|
this.showFireHazardZonesOverlay,
|
||||||
|
this.showHistoricalFiresOverlay,
|
||||||
|
this.showFirebreaksOverlay,
|
||||||
|
this.showKrasFireZonesOverlay,
|
||||||
|
this.showPlaceNamesOverlay,
|
||||||
|
this.showMunicipalityBordersOverlay,
|
||||||
|
this.hideRepeatersOnMap,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isEmpty =>
|
||||||
|
mapPrefs == null &&
|
||||||
|
drawings.isEmpty &&
|
||||||
|
currentTrail == null &&
|
||||||
|
trailHistory.isEmpty &&
|
||||||
|
importedTrail == null &&
|
||||||
|
isTrailVisible == null &&
|
||||||
|
showCadastralOverlay == null &&
|
||||||
|
showForestRoadsOverlay == null &&
|
||||||
|
showHikingTrailsOverlay == null &&
|
||||||
|
showMainRoadsOverlay == null &&
|
||||||
|
showHouseNumbersOverlay == null &&
|
||||||
|
showFireHazardZonesOverlay == null &&
|
||||||
|
showHistoricalFiresOverlay == null &&
|
||||||
|
showFirebreaksOverlay == null &&
|
||||||
|
showKrasFireZonesOverlay == null &&
|
||||||
|
showPlaceNamesOverlay == null &&
|
||||||
|
showMunicipalityBordersOverlay == null &&
|
||||||
|
hideRepeatersOnMap == null;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'mapPrefs': mapPrefs,
|
||||||
|
'drawings': drawings,
|
||||||
|
'currentTrail': currentTrail,
|
||||||
|
'trailHistory': trailHistory,
|
||||||
|
'importedTrail': importedTrail,
|
||||||
|
'isTrailVisible': isTrailVisible,
|
||||||
|
'showCadastralOverlay': showCadastralOverlay,
|
||||||
|
'showForestRoadsOverlay': showForestRoadsOverlay,
|
||||||
|
'showHikingTrailsOverlay': showHikingTrailsOverlay,
|
||||||
|
'showMainRoadsOverlay': showMainRoadsOverlay,
|
||||||
|
'showHouseNumbersOverlay': showHouseNumbersOverlay,
|
||||||
|
'showFireHazardZonesOverlay': showFireHazardZonesOverlay,
|
||||||
|
'showHistoricalFiresOverlay': showHistoricalFiresOverlay,
|
||||||
|
'showFirebreaksOverlay': showFirebreaksOverlay,
|
||||||
|
'showKrasFireZonesOverlay': showKrasFireZonesOverlay,
|
||||||
|
'showPlaceNamesOverlay': showPlaceNamesOverlay,
|
||||||
|
'showMunicipalityBordersOverlay': showMunicipalityBordersOverlay,
|
||||||
|
'hideRepeatersOnMap': hideRepeatersOnMap,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory MapWorkspaceProfileSection.fromJson(Map<String, dynamic> json) {
|
||||||
|
List<Map<String, dynamic>> decodeList(String key) {
|
||||||
|
final raw = json[key] as List<dynamic>? ?? const [];
|
||||||
|
return raw.whereType<Map<String, dynamic>>().toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return MapWorkspaceProfileSection(
|
||||||
|
mapPrefs: json['mapPrefs'] as Map<String, dynamic>?,
|
||||||
|
drawings: decodeList('drawings'),
|
||||||
|
currentTrail: json['currentTrail'] as Map<String, dynamic>?,
|
||||||
|
trailHistory: decodeList('trailHistory'),
|
||||||
|
importedTrail: json['importedTrail'] as Map<String, dynamic>?,
|
||||||
|
isTrailVisible: json['isTrailVisible'] as bool?,
|
||||||
|
showCadastralOverlay: json['showCadastralOverlay'] as bool?,
|
||||||
|
showForestRoadsOverlay: json['showForestRoadsOverlay'] as bool?,
|
||||||
|
showHikingTrailsOverlay: json['showHikingTrailsOverlay'] as bool?,
|
||||||
|
showMainRoadsOverlay: json['showMainRoadsOverlay'] as bool?,
|
||||||
|
showHouseNumbersOverlay: json['showHouseNumbersOverlay'] as bool?,
|
||||||
|
showFireHazardZonesOverlay: json['showFireHazardZonesOverlay'] as bool?,
|
||||||
|
showHistoricalFiresOverlay: json['showHistoricalFiresOverlay'] as bool?,
|
||||||
|
showFirebreaksOverlay: json['showFirebreaksOverlay'] as bool?,
|
||||||
|
showKrasFireZonesOverlay: json['showKrasFireZonesOverlay'] as bool?,
|
||||||
|
showPlaceNamesOverlay: json['showPlaceNamesOverlay'] as bool?,
|
||||||
|
showMunicipalityBordersOverlay:
|
||||||
|
json['showMunicipalityBordersOverlay'] as bool?,
|
||||||
|
hideRepeatersOnMap: json['hideRepeatersOnMap'] as bool?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class ConfigProfileSections {
|
||||||
|
final DeviceConfigProfileSection? deviceConfig;
|
||||||
|
final AppSettingsProfileSection? appSettings;
|
||||||
|
final MapWorkspaceProfileSection? mapWorkspace;
|
||||||
|
final List<Channel> channels;
|
||||||
|
|
||||||
|
const ConfigProfileSections({
|
||||||
|
this.deviceConfig,
|
||||||
|
this.appSettings,
|
||||||
|
this.mapWorkspace,
|
||||||
|
this.channels = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isEmpty =>
|
||||||
|
(deviceConfig == null || deviceConfig!.isEmpty) &&
|
||||||
|
(appSettings == null || appSettings!.isEmpty) &&
|
||||||
|
(mapWorkspace == null || mapWorkspace!.isEmpty) &&
|
||||||
|
channels.isEmpty;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'deviceConfig': deviceConfig?.toJson(),
|
||||||
|
'appSettings': appSettings?.toJson(),
|
||||||
|
'mapWorkspace': mapWorkspace?.toJson(),
|
||||||
|
'channels': channels.map((channel) => channel.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ConfigProfileSections.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawChannels = json['channels'] as List<dynamic>? ?? const [];
|
||||||
|
return ConfigProfileSections(
|
||||||
|
deviceConfig: json['deviceConfig'] is Map<String, dynamic>
|
||||||
|
? DeviceConfigProfileSection.fromJson(
|
||||||
|
json['deviceConfig'] as Map<String, dynamic>,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
appSettings: json['appSettings'] is Map<String, dynamic>
|
||||||
|
? AppSettingsProfileSection.fromJson(
|
||||||
|
json['appSettings'] as Map<String, dynamic>,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
mapWorkspace: json['mapWorkspace'] is Map<String, dynamic>
|
||||||
|
? MapWorkspaceProfileSection.fromJson(
|
||||||
|
json['mapWorkspace'] as Map<String, dynamic>,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
channels: rawChannels
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(Channel.fromJson)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class ConfigProfile {
|
||||||
|
static const String defaultProfileId = 'default';
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
final String? notes;
|
||||||
|
final ConfigProfileSections sections;
|
||||||
|
|
||||||
|
const ConfigProfile({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.sections,
|
||||||
|
this.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isDefault => id == defaultProfileId;
|
||||||
|
|
||||||
|
factory ConfigProfile.defaultProfile() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
return ConfigProfile(
|
||||||
|
id: defaultProfileId,
|
||||||
|
name: 'Default',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
sections: const ConfigProfileSections(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigProfile copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
String? notes,
|
||||||
|
ConfigProfileSections? sections,
|
||||||
|
}) {
|
||||||
|
return ConfigProfile(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
notes: notes ?? this.notes,
|
||||||
|
sections: sections ?? this.sections,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'createdAt': createdAt.toIso8601String(),
|
||||||
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
|
'notes': notes,
|
||||||
|
'sections': sections.toJson(),
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ConfigProfile.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ConfigProfile(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||||
|
notes: json['notes'] as String?,
|
||||||
|
sections: ConfigProfileSections.fromJson(
|
||||||
|
json['sections'] as Map<String, dynamic>? ?? const {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class ProfileDiff {
|
||||||
|
final List<String> changedSections;
|
||||||
|
|
||||||
|
const ProfileDiff({required this.changedSections});
|
||||||
|
}
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class ProfileTransferRecord {
|
||||||
|
final String profileId;
|
||||||
|
final String direction;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final String detail;
|
||||||
|
|
||||||
|
const ProfileTransferRecord({
|
||||||
|
required this.profileId,
|
||||||
|
required this.direction,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.detail,
|
||||||
|
});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'profileId': profileId,
|
||||||
|
'direction': direction,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
'detail': detail,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ProfileTransferRecord.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ProfileTransferRecord(
|
||||||
|
profileId: json['profileId'] as String,
|
||||||
|
direction: json['direction'] as String,
|
||||||
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||||
|
detail: json['detail'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,203 @@
|
|||||||
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 direct({int hashSize = 1}) {
|
||||||
|
if (hashSize < 1 || hashSize > maxHashSize) {
|
||||||
|
throw ArgumentError.value(hashSize, 'hashSize', 'Must be 1, 2, or 3.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final encodedPathLen = (hashSize - 1) << 6;
|
||||||
|
return ParsedContactRoute(
|
||||||
|
canonicalText: '',
|
||||||
|
hashSize: hashSize,
|
||||||
|
hopCount: 0,
|
||||||
|
encodedPathLen: encodedPathLen,
|
||||||
|
signedEncodedPathLen: toSignedDescriptor(encodedPathLen),
|
||||||
|
pathBytes: Uint8List(0),
|
||||||
|
paddedPathBytes: Uint8List(maxPathBytes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +207,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;
|
||||||
}
|
}
|
||||||
|
|||||||
39
lib/models/contact_group.dart
Normal file
39
lib/models/contact_group.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
class SavedContactGroup {
|
||||||
|
final String id;
|
||||||
|
final String sectionKey;
|
||||||
|
final String label;
|
||||||
|
final String query;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final List<String>? matchPrefixes;
|
||||||
|
final bool isAutoGroup;
|
||||||
|
|
||||||
|
const SavedContactGroup({
|
||||||
|
required this.id,
|
||||||
|
required this.sectionKey,
|
||||||
|
required this.label,
|
||||||
|
required this.query,
|
||||||
|
required this.createdAt,
|
||||||
|
this.matchPrefixes,
|
||||||
|
this.isAutoGroup = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
SavedContactGroup copyWith({
|
||||||
|
String? id,
|
||||||
|
String? sectionKey,
|
||||||
|
String? label,
|
||||||
|
String? query,
|
||||||
|
DateTime? createdAt,
|
||||||
|
List<String>? matchPrefixes,
|
||||||
|
bool? isAutoGroup,
|
||||||
|
}) {
|
||||||
|
return SavedContactGroup(
|
||||||
|
id: id ?? this.id,
|
||||||
|
sectionKey: sectionKey ?? this.sectionKey,
|
||||||
|
label: label ?? this.label,
|
||||||
|
query: query ?? this.query,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
matchPrefixes: matchPrefixes ?? this.matchPrefixes,
|
||||||
|
isAutoGroup: isAutoGroup ?? this.isAutoGroup,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
151
lib/models/custom_map_config.dart
Normal file
151
lib/models/custom_map_config.dart
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../utils/custom_map_id.dart';
|
||||||
|
|
||||||
|
class CustomMapConfig {
|
||||||
|
final String filePath;
|
||||||
|
final String displayName;
|
||||||
|
final String mapId;
|
||||||
|
final int imageWidth;
|
||||||
|
final int imageHeight;
|
||||||
|
final double? metersPerPixel;
|
||||||
|
final LatLng? calibrationPointA;
|
||||||
|
final LatLng? calibrationPointB;
|
||||||
|
|
||||||
|
const CustomMapConfig({
|
||||||
|
required this.filePath,
|
||||||
|
required this.displayName,
|
||||||
|
required this.mapId,
|
||||||
|
required this.imageWidth,
|
||||||
|
required this.imageHeight,
|
||||||
|
this.metersPerPixel,
|
||||||
|
this.calibrationPointA,
|
||||||
|
this.calibrationPointB,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isCalibrated => metersPerPixel != null && metersPerPixel! > 0;
|
||||||
|
|
||||||
|
double get _displayScale {
|
||||||
|
final heightScale = imageHeight / 90.0;
|
||||||
|
final widthScale = imageWidth / 180.0;
|
||||||
|
return math.max(1.0, math.max(heightScale, widthScale));
|
||||||
|
}
|
||||||
|
|
||||||
|
double get _displayHeight => imageHeight / _displayScale;
|
||||||
|
|
||||||
|
double get _displayWidth => imageWidth / _displayScale;
|
||||||
|
|
||||||
|
LatLngBounds get bounds =>
|
||||||
|
LatLngBounds(const LatLng(0, 0), LatLng(_displayHeight, _displayWidth));
|
||||||
|
|
||||||
|
LatLngBounds get displayBounds =>
|
||||||
|
LatLngBounds(LatLng(_displayHeight, 0), LatLng(0, _displayWidth));
|
||||||
|
|
||||||
|
LatLng toDisplayPoint(LatLng point) {
|
||||||
|
return LatLng(
|
||||||
|
_displayHeight - (point.latitude / _displayScale),
|
||||||
|
point.longitude / _displayScale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng fromDisplayPoint(LatLng point) {
|
||||||
|
return LatLng(
|
||||||
|
(_displayHeight - point.latitude) * _displayScale,
|
||||||
|
point.longitude * _displayScale,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'filePath': filePath,
|
||||||
|
'displayName': displayName,
|
||||||
|
'mapId': mapId,
|
||||||
|
'imageWidth': imageWidth,
|
||||||
|
'imageHeight': imageHeight,
|
||||||
|
'metersPerPixel': metersPerPixel,
|
||||||
|
'calibrationPointA': calibrationPointA != null
|
||||||
|
? {
|
||||||
|
'lat': calibrationPointA!.latitude,
|
||||||
|
'lon': calibrationPointA!.longitude,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
'calibrationPointB': calibrationPointB != null
|
||||||
|
? {
|
||||||
|
'lat': calibrationPointB!.latitude,
|
||||||
|
'lon': calibrationPointB!.longitude,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomMapConfig copyWith({
|
||||||
|
String? filePath,
|
||||||
|
String? displayName,
|
||||||
|
String? mapId,
|
||||||
|
int? imageWidth,
|
||||||
|
int? imageHeight,
|
||||||
|
double? metersPerPixel,
|
||||||
|
bool clearMetersPerPixel = false,
|
||||||
|
LatLng? calibrationPointA,
|
||||||
|
bool clearCalibrationPointA = false,
|
||||||
|
LatLng? calibrationPointB,
|
||||||
|
bool clearCalibrationPointB = false,
|
||||||
|
}) {
|
||||||
|
return CustomMapConfig(
|
||||||
|
filePath: filePath ?? this.filePath,
|
||||||
|
displayName: displayName ?? this.displayName,
|
||||||
|
mapId: normalizeCustomMapId(mapId) ?? this.mapId,
|
||||||
|
imageWidth: imageWidth ?? this.imageWidth,
|
||||||
|
imageHeight: imageHeight ?? this.imageHeight,
|
||||||
|
metersPerPixel: clearMetersPerPixel
|
||||||
|
? null
|
||||||
|
: (metersPerPixel ?? this.metersPerPixel),
|
||||||
|
calibrationPointA: clearCalibrationPointA
|
||||||
|
? null
|
||||||
|
: (calibrationPointA ?? this.calibrationPointA),
|
||||||
|
calibrationPointB: clearCalibrationPointB
|
||||||
|
? null
|
||||||
|
: (calibrationPointB ?? this.calibrationPointB),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static CustomMapConfig? fromJson(Map<String, dynamic>? json) {
|
||||||
|
if (json == null) return null;
|
||||||
|
|
||||||
|
final filePath = json['filePath'];
|
||||||
|
final displayName = json['displayName'];
|
||||||
|
final mapId = json['mapId'];
|
||||||
|
final imageWidth = json['imageWidth'];
|
||||||
|
final imageHeight = json['imageHeight'];
|
||||||
|
if (filePath is! String ||
|
||||||
|
displayName is! String ||
|
||||||
|
mapId is! String ||
|
||||||
|
imageWidth is! int ||
|
||||||
|
imageHeight is! int) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLng? parsePoint(dynamic raw) {
|
||||||
|
if (raw is! Map<String, dynamic>) return null;
|
||||||
|
final lat = raw['lat'];
|
||||||
|
final lon = raw['lon'];
|
||||||
|
if (lat is! num || lon is! num) return null;
|
||||||
|
return LatLng(lat.toDouble(), lon.toDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
final metersPerPixel = json['metersPerPixel'];
|
||||||
|
return CustomMapConfig(
|
||||||
|
filePath: filePath,
|
||||||
|
displayName: displayName,
|
||||||
|
mapId: normalizeCustomMapId(mapId) ?? mapId,
|
||||||
|
imageWidth: imageWidth,
|
||||||
|
imageHeight: imageHeight,
|
||||||
|
metersPerPixel: metersPerPixel is num ? metersPerPixel.toDouble() : null,
|
||||||
|
calibrationPointA: parsePoint(json['calibrationPointA']),
|
||||||
|
calibrationPointB: parsePoint(json['calibrationPointB']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,11 +14,11 @@ enum ConnectionMode {
|
|||||||
/// Direct BLE connection to MeshCore device (default)
|
/// Direct BLE connection to MeshCore device (default)
|
||||||
ble,
|
ble,
|
||||||
|
|
||||||
/// Act as SSE server - share BLE device with multiple clients
|
|
||||||
sseServer,
|
|
||||||
|
|
||||||
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
/// Direct TCP/WiFi connection to MeshCore device (port 5000)
|
||||||
tcp,
|
tcp,
|
||||||
|
|
||||||
|
/// USB serial connection (Android OTG or Web Serial API)
|
||||||
|
usb,
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ConnectionModeExtension on ConnectionMode {
|
extension ConnectionModeExtension on ConnectionMode {
|
||||||
@@ -26,10 +26,10 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
switch (this) {
|
switch (this) {
|
||||||
case ConnectionMode.ble:
|
case ConnectionMode.ble:
|
||||||
return 'Direct (BLE)';
|
return 'Direct (BLE)';
|
||||||
case ConnectionMode.sseServer:
|
|
||||||
return 'Share Device (Server)';
|
|
||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct (WiFi)';
|
return 'Direct (WiFi)';
|
||||||
|
case ConnectionMode.usb:
|
||||||
|
return 'Direct (Serial)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,10 +37,10 @@ extension ConnectionModeExtension on ConnectionMode {
|
|||||||
switch (this) {
|
switch (this) {
|
||||||
case ConnectionMode.ble:
|
case ConnectionMode.ble:
|
||||||
return 'Direct BLE connection to MeshCore device';
|
return 'Direct BLE connection to MeshCore device';
|
||||||
case ConnectionMode.sseServer:
|
|
||||||
return 'Share BLE device with multiple clients over network';
|
|
||||||
case ConnectionMode.tcp:
|
case ConnectionMode.tcp:
|
||||||
return 'Direct WiFi/TCP connection to MeshCore device';
|
return 'Direct WiFi/TCP connection to MeshCore device';
|
||||||
|
case ConnectionMode.usb:
|
||||||
|
return 'Direct serial connection to MeshCore device';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,12 @@ class DeviceInfo {
|
|||||||
final int? advLat;
|
final int? advLat;
|
||||||
final int? advLon;
|
final int? advLon;
|
||||||
final bool? manualAddContacts;
|
final bool? manualAddContacts;
|
||||||
|
final bool? autoAddUsers;
|
||||||
|
final bool? autoAddRepeaters;
|
||||||
|
final bool? autoAddRoomServers;
|
||||||
|
final bool? autoAddSensors;
|
||||||
|
final bool? autoAddOverwriteOldest;
|
||||||
|
final int? autoAddMaxHops;
|
||||||
final int? radioFreq;
|
final int? radioFreq;
|
||||||
final int? radioBw;
|
final int? radioBw;
|
||||||
final int? radioSf;
|
final int? radioSf;
|
||||||
@@ -73,12 +79,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 +96,10 @@ class DeviceInfo {
|
|||||||
|
|
||||||
// Repeat mode (firmware v9+)
|
// Repeat mode (firmware v9+)
|
||||||
final bool? clientRepeat;
|
final bool? clientRepeat;
|
||||||
|
final int? pathHashMode;
|
||||||
|
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({
|
||||||
@@ -108,6 +120,12 @@ class DeviceInfo {
|
|||||||
this.advLat,
|
this.advLat,
|
||||||
this.advLon,
|
this.advLon,
|
||||||
this.manualAddContacts,
|
this.manualAddContacts,
|
||||||
|
this.autoAddUsers,
|
||||||
|
this.autoAddRepeaters,
|
||||||
|
this.autoAddRoomServers,
|
||||||
|
this.autoAddSensors,
|
||||||
|
this.autoAddOverwriteOldest,
|
||||||
|
this.autoAddMaxHops,
|
||||||
this.radioFreq,
|
this.radioFreq,
|
||||||
this.radioBw,
|
this.radioBw,
|
||||||
this.radioSf,
|
this.radioSf,
|
||||||
@@ -124,6 +142,10 @@ class DeviceInfo {
|
|||||||
this.manufacturerModel,
|
this.manufacturerModel,
|
||||||
this.semanticVersion,
|
this.semanticVersion,
|
||||||
this.clientRepeat,
|
this.clientRepeat,
|
||||||
|
this.pathHashMode,
|
||||||
|
this.supportsSpectrumScan,
|
||||||
|
this.spectrumScanMinKhz,
|
||||||
|
this.spectrumScanMaxKhz,
|
||||||
this.allowedRepeatFreqRanges,
|
this.allowedRepeatFreqRanges,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,7 +182,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;
|
||||||
@@ -229,6 +253,12 @@ class DeviceInfo {
|
|||||||
int? advLat,
|
int? advLat,
|
||||||
int? advLon,
|
int? advLon,
|
||||||
bool? manualAddContacts,
|
bool? manualAddContacts,
|
||||||
|
bool? autoAddUsers,
|
||||||
|
bool? autoAddRepeaters,
|
||||||
|
bool? autoAddRoomServers,
|
||||||
|
bool? autoAddSensors,
|
||||||
|
bool? autoAddOverwriteOldest,
|
||||||
|
int? autoAddMaxHops,
|
||||||
int? radioFreq,
|
int? radioFreq,
|
||||||
int? radioBw,
|
int? radioBw,
|
||||||
int? radioSf,
|
int? radioSf,
|
||||||
@@ -245,6 +275,10 @@ class DeviceInfo {
|
|||||||
String? manufacturerModel,
|
String? manufacturerModel,
|
||||||
String? semanticVersion,
|
String? semanticVersion,
|
||||||
bool? clientRepeat,
|
bool? clientRepeat,
|
||||||
|
int? pathHashMode,
|
||||||
|
bool? supportsSpectrumScan,
|
||||||
|
int? spectrumScanMinKhz,
|
||||||
|
int? spectrumScanMaxKhz,
|
||||||
List<({int lower, int upper})>? allowedRepeatFreqRanges,
|
List<({int lower, int upper})>? allowedRepeatFreqRanges,
|
||||||
}) {
|
}) {
|
||||||
return DeviceInfo(
|
return DeviceInfo(
|
||||||
@@ -265,6 +299,13 @@ class DeviceInfo {
|
|||||||
advLat: advLat ?? this.advLat,
|
advLat: advLat ?? this.advLat,
|
||||||
advLon: advLon ?? this.advLon,
|
advLon: advLon ?? this.advLon,
|
||||||
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
|
manualAddContacts: manualAddContacts ?? this.manualAddContacts,
|
||||||
|
autoAddUsers: autoAddUsers ?? this.autoAddUsers,
|
||||||
|
autoAddRepeaters: autoAddRepeaters ?? this.autoAddRepeaters,
|
||||||
|
autoAddRoomServers: autoAddRoomServers ?? this.autoAddRoomServers,
|
||||||
|
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
|
||||||
|
autoAddOverwriteOldest:
|
||||||
|
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
|
||||||
|
autoAddMaxHops: autoAddMaxHops ?? this.autoAddMaxHops,
|
||||||
radioFreq: radioFreq ?? this.radioFreq,
|
radioFreq: radioFreq ?? this.radioFreq,
|
||||||
radioBw: radioBw ?? this.radioBw,
|
radioBw: radioBw ?? this.radioBw,
|
||||||
radioSf: radioSf ?? this.radioSf,
|
radioSf: radioSf ?? this.radioSf,
|
||||||
@@ -281,7 +322,12 @@ 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,
|
pathHashMode: pathHashMode ?? this.pathHashMode,
|
||||||
|
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
|
||||||
|
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
|
||||||
|
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
|
||||||
|
allowedRepeatFreqRanges:
|
||||||
|
allowedRepeatFreqRanges ?? this.allowedRepeatFreqRanges,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
lib/models/map_coordinate_space.dart
Normal file
11
lib/models/map_coordinate_space.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
enum MapCoordinateSpace {
|
||||||
|
geo,
|
||||||
|
customMap;
|
||||||
|
|
||||||
|
static MapCoordinateSpace fromName(String? value) {
|
||||||
|
return MapCoordinateSpace.values.firstWhere(
|
||||||
|
(space) => space.name == value,
|
||||||
|
orElse: () => MapCoordinateSpace.geo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,35 +2,26 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import 'map_coordinate_space.dart';
|
||||||
|
import '../utils/custom_map_id.dart';
|
||||||
|
|
||||||
/// Drawing shape type
|
/// Drawing shape type
|
||||||
enum DrawingShapeType {
|
enum DrawingShapeType { line, rectangle }
|
||||||
line,
|
|
||||||
rectangle,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drawing color enum for compact network transmission
|
/// Drawing color enum for compact network transmission
|
||||||
enum DrawingColor {
|
enum DrawingColor { red, blue, green, yellow, orange, purple, pink, cyan }
|
||||||
red, // 0
|
|
||||||
blue, // 1
|
|
||||||
green, // 2
|
|
||||||
yellow, // 3
|
|
||||||
orange, // 4
|
|
||||||
purple, // 5
|
|
||||||
pink, // 6
|
|
||||||
cyan, // 7
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drawing colors available for user selection
|
/// Drawing colors available for user selection
|
||||||
class DrawingColors {
|
class DrawingColors {
|
||||||
static const List<Color> palette = [
|
static const List<Color> palette = [
|
||||||
Colors.red, // index 0
|
Colors.red,
|
||||||
Colors.blue, // index 1
|
Colors.blue,
|
||||||
Colors.green, // index 2
|
Colors.green,
|
||||||
Colors.yellow, // index 3
|
Colors.yellow,
|
||||||
Colors.orange, // index 4
|
Colors.orange,
|
||||||
Colors.purple, // index 5
|
Colors.purple,
|
||||||
Colors.pink, // index 6
|
Colors.pink,
|
||||||
Colors.cyan, // index 7
|
Colors.cyan,
|
||||||
];
|
];
|
||||||
|
|
||||||
static String colorToName(Color color) {
|
static String colorToName(Color color) {
|
||||||
@@ -45,37 +36,36 @@ class DrawingColors {
|
|||||||
return 'Unknown';
|
return 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Color to enum index for network transmission
|
|
||||||
static int colorToIndex(Color color) {
|
static int colorToIndex(Color color) {
|
||||||
for (int i = 0; i < palette.length; i++) {
|
for (int i = 0; i < palette.length; i++) {
|
||||||
if (palette[i].toARGB32() == color.toARGB32()) {
|
if (palette[i].toARGB32() == color.toARGB32()) {
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 0; // Default to red if not found
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert enum index to Color for network reception
|
|
||||||
static Color indexToColor(int index) {
|
static Color indexToColor(int index) {
|
||||||
if (index >= 0 && index < palette.length) {
|
if (index >= 0 && index < palette.length) {
|
||||||
return palette[index];
|
return palette[index];
|
||||||
}
|
}
|
||||||
return palette[0]; // Default to red if invalid index
|
return palette[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Base class for map drawings
|
|
||||||
abstract class MapDrawing {
|
abstract class MapDrawing {
|
||||||
final String id;
|
final String id;
|
||||||
final DrawingShapeType type;
|
final DrawingShapeType type;
|
||||||
final Color color;
|
final Color color;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final String? senderName; // Name of sender (null if local drawing)
|
final String? senderName;
|
||||||
final bool isReceived; // True if drawing was received from another node
|
final bool isReceived;
|
||||||
final String? messageId; // ID of the source message (for navigation)
|
final String? messageId;
|
||||||
final bool isShared; // Whether drawing has been broadcast over mesh
|
final bool isShared;
|
||||||
final bool isSent; // Whether this is a sent drawing (vs received)
|
final bool isSent;
|
||||||
final bool isHidden; // Temporary visibility toggle (session only, not persisted)
|
final bool isHidden;
|
||||||
|
final MapCoordinateSpace coordinateSpace;
|
||||||
|
final String? mapId;
|
||||||
|
|
||||||
MapDrawing({
|
MapDrawing({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -88,79 +78,79 @@ abstract class MapDrawing {
|
|||||||
this.isShared = false,
|
this.isShared = false,
|
||||||
this.isSent = false,
|
this.isSent = false,
|
||||||
this.isHidden = false,
|
this.isHidden = false,
|
||||||
|
this.coordinateSpace = MapCoordinateSpace.geo,
|
||||||
|
this.mapId,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Convert to JSON for persistence
|
bool get isCustomMap => coordinateSpace == MapCoordinateSpace.customMap;
|
||||||
|
|
||||||
Map<String, dynamic> toJson();
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
/// Convert to JSON for network transmission (compact format)
|
|
||||||
/// Uses short field names and excludes createdAt to minimize message size
|
|
||||||
/// Sender will be fetched from packet metadata
|
|
||||||
Map<String, dynamic> toNetworkJson();
|
Map<String, dynamic> toNetworkJson();
|
||||||
|
|
||||||
/// Parse network JSON (compact format)
|
|
||||||
/// senderName and messageId will be populated from packet metadata
|
|
||||||
static MapDrawing? fromNetworkJson(
|
static MapDrawing? fromNetworkJson(
|
||||||
Map<String, dynamic> json, {
|
Map<String, dynamic> json, {
|
||||||
String? senderName,
|
String? senderName,
|
||||||
String? messageId,
|
String? messageId,
|
||||||
|
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||||
|
String? mapId,
|
||||||
}) {
|
}) {
|
||||||
final typeNum = json['t'] as int?;
|
final typeNum = json['t'] as int?;
|
||||||
if (typeNum == null || typeNum < 0 || typeNum >= DrawingShapeType.values.length) {
|
if (typeNum == null ||
|
||||||
|
typeNum < 0 ||
|
||||||
|
typeNum >= DrawingShapeType.values.length) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final type = DrawingShapeType.values[typeNum];
|
final type = DrawingShapeType.values[typeNum];
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case DrawingShapeType.line:
|
case DrawingShapeType.line:
|
||||||
return LineDrawing.fromNetworkJson(
|
return LineDrawing.fromNetworkJson(
|
||||||
json,
|
json,
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
|
coordinateSpace: coordinateSpace,
|
||||||
|
mapId: mapId,
|
||||||
);
|
);
|
||||||
case DrawingShapeType.rectangle:
|
case DrawingShapeType.rectangle:
|
||||||
return RectangleDrawing.fromNetworkJson(
|
return RectangleDrawing.fromNetworkJson(
|
||||||
json,
|
json,
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
messageId: messageId,
|
messageId: messageId,
|
||||||
|
coordinateSpace: coordinateSpace,
|
||||||
|
mapId: mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create from JSON
|
|
||||||
static MapDrawing? fromJson(Map<String, dynamic> json) {
|
static MapDrawing? fromJson(Map<String, dynamic> json) {
|
||||||
final typeStr = json['type'] as String?;
|
final typeStr = json['type'] as String?;
|
||||||
if (typeStr == null) return null;
|
if (typeStr == null) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final type = DrawingShapeType.values.firstWhere(
|
final type = DrawingShapeType.values.firstWhere(
|
||||||
(e) => e.toString() == 'DrawingShapeType.$typeStr',
|
(value) => value.name == typeStr,
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case DrawingShapeType.line:
|
case DrawingShapeType.line:
|
||||||
return LineDrawing.fromJson(json);
|
return LineDrawing.fromJson(json);
|
||||||
case DrawingShapeType.rectangle:
|
case DrawingShapeType.rectangle:
|
||||||
return RectangleDrawing.fromJson(json);
|
return RectangleDrawing.fromJson(json);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the center point of the drawing
|
|
||||||
LatLng getCenter();
|
LatLng getCenter();
|
||||||
|
|
||||||
/// Get the bounds of the drawing
|
|
||||||
LatLngBounds getBounds();
|
LatLngBounds getBounds();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Line drawing on map
|
|
||||||
class LineDrawing extends MapDrawing {
|
class LineDrawing extends MapDrawing {
|
||||||
final List<LatLng> points;
|
final List<LatLng> points;
|
||||||
|
|
||||||
@@ -175,6 +165,8 @@ class LineDrawing extends MapDrawing {
|
|||||||
super.isShared,
|
super.isShared,
|
||||||
super.isSent,
|
super.isSent,
|
||||||
super.isHidden,
|
super.isHidden,
|
||||||
|
super.coordinateSpace,
|
||||||
|
super.mapId,
|
||||||
}) : super(type: DrawingShapeType.line);
|
}) : super(type: DrawingShapeType.line);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -184,31 +176,51 @@ class LineDrawing extends MapDrawing {
|
|||||||
'type': type.name,
|
'type': type.name,
|
||||||
'color': color.toARGB32(),
|
'color': color.toARGB32(),
|
||||||
'createdAt': createdAt.toIso8601String(),
|
'createdAt': createdAt.toIso8601String(),
|
||||||
'points': points.map((p) => {'lat': p.latitude, 'lon': p.longitude}).toList(),
|
'points': points
|
||||||
|
.map((point) => {'lat': point.latitude, 'lon': point.longitude})
|
||||||
|
.toList(),
|
||||||
'isShared': isShared,
|
'isShared': isShared,
|
||||||
// Note: isHidden is not persisted - it's session-only
|
'coordinateSpace': coordinateSpace.name,
|
||||||
|
'mapId': normalizeCustomMapId(mapId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toNetworkJson() {
|
Map<String, dynamic> toNetworkJson() {
|
||||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), p=points
|
final payload = <String, dynamic>{
|
||||||
// Points are encoded as flat array [lat1,lon1,lat2,lon2,...]
|
|
||||||
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
|
|
||||||
// Sender is fetched from packet metadata, not included in JSON
|
|
||||||
return {
|
|
||||||
't': type.index,
|
't': type.index,
|
||||||
'c': DrawingColors.colorToIndex(color),
|
'c': DrawingColors.colorToIndex(color),
|
||||||
'p': points.expand((p) => [
|
|
||||||
double.parse(p.latitude.toStringAsFixed(5)),
|
|
||||||
double.parse(p.longitude.toStringAsFixed(5)),
|
|
||||||
]).toList(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||||
|
payload['m'] = normalizeCustomMapId(mapId);
|
||||||
|
payload['p'] = points
|
||||||
|
.expand((point) => [point.latitude.round(), point.longitude.round()])
|
||||||
|
.toList();
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload['p'] = points
|
||||||
|
.expand(
|
||||||
|
(point) => [
|
||||||
|
double.parse(point.latitude.toStringAsFixed(5)),
|
||||||
|
double.parse(point.longitude.toStringAsFixed(5)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
static LineDrawing fromJson(Map<String, dynamic> json) {
|
static LineDrawing fromJson(Map<String, dynamic> json) {
|
||||||
final pointsJson = json['points'] as List<dynamic>;
|
final pointsJson = json['points'] as List<dynamic>;
|
||||||
final points = pointsJson.map((p) => LatLng(p['lat'] as double, p['lon'] as double)).toList();
|
final points = pointsJson
|
||||||
|
.map(
|
||||||
|
(point) => LatLng(
|
||||||
|
(point['lat'] as num).toDouble(),
|
||||||
|
(point['lon'] as num).toDouble(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
final senderName = json['sender'] as String?;
|
final senderName = json['sender'] as String?;
|
||||||
|
|
||||||
return LineDrawing(
|
return LineDrawing(
|
||||||
@@ -217,8 +229,12 @@ class LineDrawing extends MapDrawing {
|
|||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
points: points,
|
points: points,
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
isReceived: senderName != null, // Mark as received if sender is present
|
isReceived: senderName != null,
|
||||||
isShared: json['isShared'] as bool? ?? false,
|
isShared: json['isShared'] as bool? ?? false,
|
||||||
|
coordinateSpace: MapCoordinateSpace.fromName(
|
||||||
|
json['coordinateSpace'] as String?,
|
||||||
|
),
|
||||||
|
mapId: normalizeCustomMapId(json['mapId'] as String?),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,42 +242,60 @@ class LineDrawing extends MapDrawing {
|
|||||||
Map<String, dynamic> json, {
|
Map<String, dynamic> json, {
|
||||||
String? senderName,
|
String? senderName,
|
||||||
String? messageId,
|
String? messageId,
|
||||||
|
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||||
|
String? mapId,
|
||||||
}) {
|
}) {
|
||||||
// Parse ultra-compact format
|
final flatPoints = (json['p'] as List<dynamic>).cast<num>();
|
||||||
final pointsFlat = (json['p'] as List<dynamic>).cast<double>();
|
|
||||||
final points = <LatLng>[];
|
final points = <LatLng>[];
|
||||||
for (int i = 0; i < pointsFlat.length; i += 2) {
|
for (int i = 0; i < flatPoints.length; i += 2) {
|
||||||
points.add(LatLng(pointsFlat[i], pointsFlat[i + 1]));
|
points.add(
|
||||||
|
LatLng(flatPoints[i].toDouble(), flatPoints[i + 1].toDouble()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return LineDrawing(
|
return LineDrawing(
|
||||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
color: DrawingColors.indexToColor(json['c'] as int),
|
color: DrawingColors.indexToColor(json['c'] as int),
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
points: points,
|
points: points,
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
isReceived: true,
|
isReceived: true,
|
||||||
messageId: messageId, // Link to source message
|
messageId: messageId,
|
||||||
isShared: false, // Received drawings are not marked as shared
|
coordinateSpace: coordinateSpace,
|
||||||
|
mapId: normalizeCustomMapId(mapId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a copy with updated points
|
LineDrawing copyWith({
|
||||||
LineDrawing copyWith({List<LatLng>? points}) {
|
List<LatLng>? points,
|
||||||
|
bool? isHidden,
|
||||||
|
bool? isShared,
|
||||||
|
bool? isReceived,
|
||||||
|
String? messageId,
|
||||||
|
String? senderName,
|
||||||
|
MapCoordinateSpace? coordinateSpace,
|
||||||
|
String? mapId,
|
||||||
|
}) {
|
||||||
return LineDrawing(
|
return LineDrawing(
|
||||||
id: id,
|
id: id,
|
||||||
color: color,
|
color: color,
|
||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
points: points ?? this.points,
|
points: points ?? this.points,
|
||||||
|
isHidden: isHidden ?? this.isHidden,
|
||||||
|
isShared: isShared ?? this.isShared,
|
||||||
|
isReceived: isReceived ?? this.isReceived,
|
||||||
|
messageId: messageId ?? this.messageId,
|
||||||
|
senderName: senderName ?? this.senderName,
|
||||||
|
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||||
|
mapId: mapId ?? this.mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
LatLng getCenter() {
|
LatLng getCenter() {
|
||||||
if (points.isEmpty) return LatLng(0, 0);
|
if (points.isEmpty) return const LatLng(0, 0);
|
||||||
if (points.length == 1) return points[0];
|
if (points.length == 1) return points[0];
|
||||||
|
|
||||||
// Calculate center as average of all points
|
|
||||||
double sumLat = 0;
|
double sumLat = 0;
|
||||||
double sumLon = 0;
|
double sumLon = 0;
|
||||||
for (final point in points) {
|
for (final point in points) {
|
||||||
@@ -273,8 +307,12 @@ class LineDrawing extends MapDrawing {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
LatLngBounds getBounds() {
|
LatLngBounds getBounds() {
|
||||||
if (points.isEmpty) return LatLngBounds(LatLng(0, 0), LatLng(0, 0));
|
if (points.isEmpty) {
|
||||||
if (points.length == 1) return LatLngBounds(points[0], points[0]);
|
return LatLngBounds(const LatLng(0, 0), const LatLng(0, 0));
|
||||||
|
}
|
||||||
|
if (points.length == 1) {
|
||||||
|
return LatLngBounds(points[0], points[0]);
|
||||||
|
}
|
||||||
|
|
||||||
double minLat = points[0].latitude;
|
double minLat = points[0].latitude;
|
||||||
double maxLat = points[0].latitude;
|
double maxLat = points[0].latitude;
|
||||||
@@ -292,7 +330,6 @@ class LineDrawing extends MapDrawing {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rectangle drawing on map
|
|
||||||
class RectangleDrawing extends MapDrawing {
|
class RectangleDrawing extends MapDrawing {
|
||||||
final LatLng topLeft;
|
final LatLng topLeft;
|
||||||
final LatLng bottomRight;
|
final LatLng bottomRight;
|
||||||
@@ -309,16 +346,17 @@ class RectangleDrawing extends MapDrawing {
|
|||||||
super.isShared,
|
super.isShared,
|
||||||
super.isSent,
|
super.isSent,
|
||||||
super.isHidden,
|
super.isHidden,
|
||||||
|
super.coordinateSpace,
|
||||||
|
super.mapId,
|
||||||
}) : super(type: DrawingShapeType.rectangle);
|
}) : super(type: DrawingShapeType.rectangle);
|
||||||
|
|
||||||
/// Get all corner points for rendering
|
|
||||||
List<LatLng> get corners => [
|
List<LatLng> get corners => [
|
||||||
topLeft,
|
topLeft,
|
||||||
LatLng(topLeft.latitude, bottomRight.longitude), // top right
|
LatLng(topLeft.latitude, bottomRight.longitude),
|
||||||
bottomRight,
|
bottomRight,
|
||||||
LatLng(bottomRight.latitude, topLeft.longitude), // bottom left
|
LatLng(bottomRight.latitude, topLeft.longitude),
|
||||||
topLeft, // close the rectangle
|
topLeft,
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
@@ -328,27 +366,41 @@ class RectangleDrawing extends MapDrawing {
|
|||||||
'color': color.toARGB32(),
|
'color': color.toARGB32(),
|
||||||
'createdAt': createdAt.toIso8601String(),
|
'createdAt': createdAt.toIso8601String(),
|
||||||
'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude},
|
'topLeft': {'lat': topLeft.latitude, 'lon': topLeft.longitude},
|
||||||
'bottomRight': {'lat': bottomRight.latitude, 'lon': bottomRight.longitude},
|
'bottomRight': {
|
||||||
|
'lat': bottomRight.latitude,
|
||||||
|
'lon': bottomRight.longitude,
|
||||||
|
},
|
||||||
'isShared': isShared,
|
'isShared': isShared,
|
||||||
// Note: isHidden is not persisted - it's session-only
|
'coordinateSpace': coordinateSpace.name,
|
||||||
|
'mapId': normalizeCustomMapId(mapId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toNetworkJson() {
|
Map<String, dynamic> toNetworkJson() {
|
||||||
// Ultra-compact format: t=type (0=line, 1=rect), c=color index (0-7), b=bounds [lat1,lon1,lat2,lon2]
|
final payload = <String, dynamic>{
|
||||||
// Coordinates rounded to 5 decimal places (~1m precision, like SAR markers)
|
|
||||||
// Sender is fetched from packet metadata, not included in JSON
|
|
||||||
return {
|
|
||||||
't': type.index,
|
't': type.index,
|
||||||
'c': DrawingColors.colorToIndex(color),
|
'c': DrawingColors.colorToIndex(color),
|
||||||
'b': [
|
|
||||||
double.parse(topLeft.latitude.toStringAsFixed(5)),
|
|
||||||
double.parse(topLeft.longitude.toStringAsFixed(5)),
|
|
||||||
double.parse(bottomRight.latitude.toStringAsFixed(5)),
|
|
||||||
double.parse(bottomRight.longitude.toStringAsFixed(5)),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||||
|
payload['m'] = normalizeCustomMapId(mapId);
|
||||||
|
payload['b'] = [
|
||||||
|
topLeft.latitude.round(),
|
||||||
|
topLeft.longitude.round(),
|
||||||
|
bottomRight.latitude.round(),
|
||||||
|
bottomRight.longitude.round(),
|
||||||
|
];
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
payload['b'] = [
|
||||||
|
double.parse(topLeft.latitude.toStringAsFixed(5)),
|
||||||
|
double.parse(topLeft.longitude.toStringAsFixed(5)),
|
||||||
|
double.parse(bottomRight.latitude.toStringAsFixed(5)),
|
||||||
|
double.parse(bottomRight.longitude.toStringAsFixed(5)),
|
||||||
|
];
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
static RectangleDrawing fromJson(Map<String, dynamic> json) {
|
static RectangleDrawing fromJson(Map<String, dynamic> json) {
|
||||||
@@ -360,11 +412,21 @@ class RectangleDrawing extends MapDrawing {
|
|||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
color: Color(json['color'] as int),
|
color: Color(json['color'] as int),
|
||||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||||
topLeft: LatLng(topLeftJson['lat'] as double, topLeftJson['lon'] as double),
|
topLeft: LatLng(
|
||||||
bottomRight: LatLng(bottomRightJson['lat'] as double, bottomRightJson['lon'] as double),
|
(topLeftJson['lat'] as num).toDouble(),
|
||||||
|
(topLeftJson['lon'] as num).toDouble(),
|
||||||
|
),
|
||||||
|
bottomRight: LatLng(
|
||||||
|
(bottomRightJson['lat'] as num).toDouble(),
|
||||||
|
(bottomRightJson['lon'] as num).toDouble(),
|
||||||
|
),
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
isReceived: senderName != null, // Mark as received if sender is present
|
isReceived: senderName != null,
|
||||||
isShared: json['isShared'] as bool? ?? false,
|
isShared: json['isShared'] as bool? ?? false,
|
||||||
|
coordinateSpace: MapCoordinateSpace.fromName(
|
||||||
|
json['coordinateSpace'] as String?,
|
||||||
|
),
|
||||||
|
mapId: normalizeCustomMapId(json['mapId'] as String?),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,27 +434,35 @@ class RectangleDrawing extends MapDrawing {
|
|||||||
Map<String, dynamic> json, {
|
Map<String, dynamic> json, {
|
||||||
String? senderName,
|
String? senderName,
|
||||||
String? messageId,
|
String? messageId,
|
||||||
|
MapCoordinateSpace coordinateSpace = MapCoordinateSpace.geo,
|
||||||
|
String? mapId,
|
||||||
}) {
|
}) {
|
||||||
// Parse ultra-compact format
|
final bounds = (json['b'] as List<dynamic>).cast<num>();
|
||||||
final bounds = (json['b'] as List<dynamic>).cast<double>();
|
|
||||||
|
|
||||||
return RectangleDrawing(
|
return RectangleDrawing(
|
||||||
id: DateTime.now().millisecondsSinceEpoch.toString(), // Generate new ID
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
color: DrawingColors.indexToColor(json['c'] as int),
|
color: DrawingColors.indexToColor(json['c'] as int),
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
topLeft: LatLng(bounds[0], bounds[1]),
|
topLeft: LatLng(bounds[0].toDouble(), bounds[1].toDouble()),
|
||||||
bottomRight: LatLng(bounds[2], bounds[3]),
|
bottomRight: LatLng(bounds[2].toDouble(), bounds[3].toDouble()),
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
isReceived: true,
|
isReceived: true,
|
||||||
messageId: messageId, // Link to source message
|
messageId: messageId,
|
||||||
isShared: false, // Received drawings are not marked as shared
|
coordinateSpace: coordinateSpace,
|
||||||
|
mapId: normalizeCustomMapId(mapId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a copy with updated corners
|
|
||||||
RectangleDrawing copyWith({
|
RectangleDrawing copyWith({
|
||||||
LatLng? topLeft,
|
LatLng? topLeft,
|
||||||
LatLng? bottomRight,
|
LatLng? bottomRight,
|
||||||
|
bool? isHidden,
|
||||||
|
bool? isShared,
|
||||||
|
bool? isReceived,
|
||||||
|
String? messageId,
|
||||||
|
String? senderName,
|
||||||
|
MapCoordinateSpace? coordinateSpace,
|
||||||
|
String? mapId,
|
||||||
}) {
|
}) {
|
||||||
return RectangleDrawing(
|
return RectangleDrawing(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -400,12 +470,18 @@ class RectangleDrawing extends MapDrawing {
|
|||||||
createdAt: createdAt,
|
createdAt: createdAt,
|
||||||
topLeft: topLeft ?? this.topLeft,
|
topLeft: topLeft ?? this.topLeft,
|
||||||
bottomRight: bottomRight ?? this.bottomRight,
|
bottomRight: bottomRight ?? this.bottomRight,
|
||||||
|
isHidden: isHidden ?? this.isHidden,
|
||||||
|
isShared: isShared ?? this.isShared,
|
||||||
|
isReceived: isReceived ?? this.isReceived,
|
||||||
|
messageId: messageId ?? this.messageId,
|
||||||
|
senderName: senderName ?? this.senderName,
|
||||||
|
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||||
|
mapId: mapId ?? this.mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
LatLng getCenter() {
|
LatLng getCenter() {
|
||||||
// Center is the midpoint between top-left and bottom-right
|
|
||||||
return LatLng(
|
return LatLng(
|
||||||
(topLeft.latitude + bottomRight.latitude) / 2,
|
(topLeft.latitude + bottomRight.latitude) / 2,
|
||||||
(topLeft.longitude + bottomRight.longitude) / 2,
|
(topLeft.longitude + bottomRight.longitude) / 2,
|
||||||
@@ -414,7 +490,6 @@ class RectangleDrawing extends MapDrawing {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
LatLngBounds getBounds() {
|
LatLngBounds getBounds() {
|
||||||
// Bounds are simply the two corners
|
|
||||||
return LatLngBounds(topLeft, bottomRight);
|
return LatLngBounds(topLeft, bottomRight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export 'package:meshcore_client/meshcore_client.dart'
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:meshcore_client/meshcore_client.dart';
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
import 'sar_marker.dart';
|
import 'sar_marker.dart';
|
||||||
|
import 'map_coordinate_space.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.dart';
|
||||||
|
|
||||||
extension MessageVoiceExtension on Message {
|
extension MessageVoiceExtension on Message {
|
||||||
@@ -47,7 +48,7 @@ extension MessageSarExtension on Message {
|
|||||||
|
|
||||||
/// Convert to a [SarMarker] if this message contains SAR data.
|
/// Convert to a [SarMarker] if this message contains SAR data.
|
||||||
SarMarker? toSarMarker() {
|
SarMarker? toSarMarker() {
|
||||||
if (!isSarMarker || sarMarkerType == null || sarGpsCoordinates == null) {
|
if (!isSarMarker || sarMarkerType == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,16 +58,26 @@ extension MessageSarExtension on Message {
|
|||||||
debugPrint(' message.sarMarkerType: $sarMarkerType');
|
debugPrint(' message.sarMarkerType: $sarMarkerType');
|
||||||
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
|
debugPrint(' message.sarCustomEmoji: "$sarCustomEmoji"');
|
||||||
|
|
||||||
|
final coordinateSpace = sarCustomMapPoint != null && sarCustomMapId != null
|
||||||
|
? MapCoordinateSpace.customMap
|
||||||
|
: MapCoordinateSpace.geo;
|
||||||
|
final location = sarCustomMapPoint ?? sarGpsCoordinates;
|
||||||
|
if (location == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return SarMarker(
|
return SarMarker(
|
||||||
id: id,
|
id: id,
|
||||||
type: sarMarkerType!,
|
type: sarMarkerType!,
|
||||||
location: sarGpsCoordinates!,
|
location: location,
|
||||||
timestamp: sentAt,
|
timestamp: sentAt,
|
||||||
senderPublicKey: senderPublicKeyPrefix,
|
senderPublicKey: senderPublicKeyPrefix,
|
||||||
senderName: senderName,
|
senderName: senderName,
|
||||||
notes: sarNotes,
|
notes: sarNotes,
|
||||||
customEmoji: sarCustomEmoji,
|
customEmoji: sarCustomEmoji,
|
||||||
colorIndex: sarColorIndex,
|
colorIndex: sarColorIndex,
|
||||||
|
coordinateSpace: coordinateSpace,
|
||||||
|
mapId: sarCustomMapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
lib/models/message_contact_location.dart
Normal file
63
lib/models/message_contact_location.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
140
lib/models/message_reception_details.dart
Normal file
140
lib/models/message_reception_details.dart
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
const int _transmitEstimateToleranceMs = 1500;
|
||||||
|
|
||||||
|
int? sanitizeEstimatedTransmitMs({
|
||||||
|
required int? estimatedTransmitMs,
|
||||||
|
required int? senderToReceiptMs,
|
||||||
|
}) {
|
||||||
|
if (estimatedTransmitMs == null || estimatedTransmitMs <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (senderToReceiptMs == null || senderToReceiptMs <= 0) {
|
||||||
|
return estimatedTransmitMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender timestamps are second-granularity, so allow a small cushion before
|
||||||
|
// treating the estimate as impossible for the observed delivery time.
|
||||||
|
if (estimatedTransmitMs > senderToReceiptMs + _transmitEstimateToleranceMs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return estimatedTransmitMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageReceptionDetails {
|
||||||
|
final DateTime capturedAt;
|
||||||
|
final DateTime? packetLoggedAt;
|
||||||
|
final int? rssiDbm;
|
||||||
|
final double? snrDb;
|
||||||
|
final List<int>? pathBytes;
|
||||||
|
final int? senderToReceiptMs;
|
||||||
|
final int? estimatedTransmitMs;
|
||||||
|
final int? postTransmitDelayMs;
|
||||||
|
final int receivedCopies;
|
||||||
|
|
||||||
|
const MessageReceptionDetails({
|
||||||
|
required this.capturedAt,
|
||||||
|
this.packetLoggedAt,
|
||||||
|
this.rssiDbm,
|
||||||
|
this.snrDb,
|
||||||
|
this.pathBytes,
|
||||||
|
this.senderToReceiptMs,
|
||||||
|
this.estimatedTransmitMs,
|
||||||
|
this.postTransmitDelayMs,
|
||||||
|
this.receivedCopies = 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
||||||
|
? null
|
||||||
|
: pathBytes!.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':');
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'capturedAtMillis': capturedAt.millisecondsSinceEpoch,
|
||||||
|
'packetLoggedAtMillis': packetLoggedAt?.millisecondsSinceEpoch,
|
||||||
|
'rssiDbm': rssiDbm,
|
||||||
|
'snrDb': snrDb,
|
||||||
|
'pathBytes': pathBytes,
|
||||||
|
'senderToReceiptMs': senderToReceiptMs,
|
||||||
|
'estimatedTransmitMs': estimatedTransmitMs,
|
||||||
|
'postTransmitDelayMs': postTransmitDelayMs,
|
||||||
|
'receivedCopies': receivedCopies,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageReceptionDetails copyWith({
|
||||||
|
DateTime? capturedAt,
|
||||||
|
DateTime? packetLoggedAt,
|
||||||
|
int? rssiDbm,
|
||||||
|
double? snrDb,
|
||||||
|
List<int>? pathBytes,
|
||||||
|
int? senderToReceiptMs,
|
||||||
|
int? estimatedTransmitMs,
|
||||||
|
int? postTransmitDelayMs,
|
||||||
|
int? receivedCopies,
|
||||||
|
}) {
|
||||||
|
return MessageReceptionDetails(
|
||||||
|
capturedAt: capturedAt ?? this.capturedAt,
|
||||||
|
packetLoggedAt: packetLoggedAt ?? this.packetLoggedAt,
|
||||||
|
rssiDbm: rssiDbm ?? this.rssiDbm,
|
||||||
|
snrDb: snrDb ?? this.snrDb,
|
||||||
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
|
senderToReceiptMs: senderToReceiptMs ?? this.senderToReceiptMs,
|
||||||
|
estimatedTransmitMs: estimatedTransmitMs ?? this.estimatedTransmitMs,
|
||||||
|
postTransmitDelayMs: postTransmitDelayMs ?? this.postTransmitDelayMs,
|
||||||
|
receivedCopies: receivedCopies ?? this.receivedCopies,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageReceptionDetails mergeDuplicate({
|
||||||
|
MessageReceptionDetails? existing,
|
||||||
|
MessageReceptionDetails? incoming,
|
||||||
|
}) {
|
||||||
|
final base =
|
||||||
|
existing ??
|
||||||
|
incoming ??
|
||||||
|
MessageReceptionDetails(capturedAt: DateTime.now());
|
||||||
|
|
||||||
|
return base.copyWith(
|
||||||
|
capturedAt: incoming?.capturedAt ?? existing?.capturedAt,
|
||||||
|
packetLoggedAt: incoming?.packetLoggedAt ?? existing?.packetLoggedAt,
|
||||||
|
rssiDbm: incoming?.rssiDbm ?? existing?.rssiDbm,
|
||||||
|
snrDb: incoming?.snrDb ?? existing?.snrDb,
|
||||||
|
pathBytes: incoming?.pathBytes ?? existing?.pathBytes,
|
||||||
|
senderToReceiptMs:
|
||||||
|
incoming?.senderToReceiptMs ?? existing?.senderToReceiptMs,
|
||||||
|
estimatedTransmitMs:
|
||||||
|
incoming?.estimatedTransmitMs ?? existing?.estimatedTransmitMs,
|
||||||
|
postTransmitDelayMs:
|
||||||
|
incoming?.postTransmitDelayMs ?? existing?.postTransmitDelayMs,
|
||||||
|
receivedCopies:
|
||||||
|
(existing?.receivedCopies ?? 1) + (incoming?.receivedCopies ?? 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
||||||
|
final capturedAtMillis = json['capturedAtMillis'];
|
||||||
|
if (capturedAtMillis is! int) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pathBytes = json['pathBytes'];
|
||||||
|
return MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(capturedAtMillis),
|
||||||
|
packetLoggedAt: json['packetLoggedAtMillis'] is int
|
||||||
|
? DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
json['packetLoggedAtMillis'] as int,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
rssiDbm: json['rssiDbm'] as int?,
|
||||||
|
snrDb: (json['snrDb'] as num?)?.toDouble(),
|
||||||
|
pathBytes: pathBytes is List
|
||||||
|
? pathBytes.whereType<num>().map((b) => b.toInt()).toList()
|
||||||
|
: null,
|
||||||
|
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
||||||
|
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
||||||
|
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
||||||
|
receivedCopies: json['receivedCopies'] as int? ?? 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
lib/models/message_route_metadata.dart
Normal file
79
lib/models/message_route_metadata.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import 'path_selection.dart';
|
||||||
|
|
||||||
|
class MessageRouteMetadata {
|
||||||
|
final PathSelectionMode mode;
|
||||||
|
final bool routerFallbackAttempted;
|
||||||
|
final String? relayName;
|
||||||
|
final String? relayKey6;
|
||||||
|
final String? canonicalPath;
|
||||||
|
final int? hopCount;
|
||||||
|
|
||||||
|
const MessageRouteMetadata({
|
||||||
|
required this.mode,
|
||||||
|
required this.routerFallbackAttempted,
|
||||||
|
this.relayName,
|
||||||
|
this.relayKey6,
|
||||||
|
this.canonicalPath,
|
||||||
|
this.hopCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MessageRouteMetadata.fromSelection(
|
||||||
|
PathSelection selection, {
|
||||||
|
required bool routerFallbackAttempted,
|
||||||
|
}) {
|
||||||
|
return MessageRouteMetadata(
|
||||||
|
mode: selection.mode,
|
||||||
|
routerFallbackAttempted: routerFallbackAttempted,
|
||||||
|
relayName: selection.relayName,
|
||||||
|
relayKey6: selection.relayKey6,
|
||||||
|
canonicalPath: selection.canonicalPath.isEmpty
|
||||||
|
? null
|
||||||
|
: selection.canonicalPath,
|
||||||
|
hopCount: selection.hopCount > 0 ? selection.hopCount : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get modeLabel {
|
||||||
|
switch (mode) {
|
||||||
|
case PathSelectionMode.directCurrent:
|
||||||
|
return 'Current direct path';
|
||||||
|
case PathSelectionMode.directHistorical:
|
||||||
|
return 'Rotated direct path';
|
||||||
|
case PathSelectionMode.flood:
|
||||||
|
return 'Flood route';
|
||||||
|
case PathSelectionMode.nearestRouter:
|
||||||
|
final suffix = relayName?.trim().isNotEmpty == true
|
||||||
|
? ' via $relayName'
|
||||||
|
: relayKey6?.trim().isNotEmpty == true
|
||||||
|
? ' via $relayKey6'
|
||||||
|
: '';
|
||||||
|
return 'Nearest router$suffix';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'mode': mode.name,
|
||||||
|
'router_fallback_attempted': routerFallbackAttempted,
|
||||||
|
'relay_name': relayName,
|
||||||
|
'relay_key6': relayKey6,
|
||||||
|
'canonical_path': canonicalPath,
|
||||||
|
'hop_count': hopCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
factory MessageRouteMetadata.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MessageRouteMetadata(
|
||||||
|
mode: PathSelectionMode.values.firstWhere(
|
||||||
|
(value) => value.name == json['mode'],
|
||||||
|
orElse: () => PathSelectionMode.directCurrent,
|
||||||
|
),
|
||||||
|
routerFallbackAttempted:
|
||||||
|
json['router_fallback_attempted'] as bool? ?? false,
|
||||||
|
relayName: json['relay_name'] as String?,
|
||||||
|
relayKey6: json['relay_key6'] as String?,
|
||||||
|
canonicalPath: json['canonical_path'] as String?,
|
||||||
|
hopCount: json['hop_count'] as int?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
142
lib/models/message_transfer_details.dart
Normal file
142
lib/models/message_transfer_details.dart
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
class MessageTransferDownloader {
|
||||||
|
final String requesterKey6;
|
||||||
|
final String? requesterName;
|
||||||
|
final int transferCount;
|
||||||
|
final DateTime lastTransferredAt;
|
||||||
|
|
||||||
|
const MessageTransferDownloader({
|
||||||
|
required this.requesterKey6,
|
||||||
|
this.requesterName,
|
||||||
|
required this.transferCount,
|
||||||
|
required this.lastTransferredAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
MessageTransferDownloader copyWith({
|
||||||
|
String? requesterKey6,
|
||||||
|
String? requesterName,
|
||||||
|
int? transferCount,
|
||||||
|
DateTime? lastTransferredAt,
|
||||||
|
}) {
|
||||||
|
return MessageTransferDownloader(
|
||||||
|
requesterKey6: requesterKey6 ?? this.requesterKey6,
|
||||||
|
requesterName: requesterName ?? this.requesterName,
|
||||||
|
transferCount: transferCount ?? this.transferCount,
|
||||||
|
lastTransferredAt: lastTransferredAt ?? this.lastTransferredAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'requesterKey6': requesterKey6,
|
||||||
|
'requesterName': requesterName,
|
||||||
|
'transferCount': transferCount,
|
||||||
|
'lastTransferredAtMillis': lastTransferredAt.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageTransferDownloader? fromJson(Map<String, dynamic> json) {
|
||||||
|
final requesterKey6 = json['requesterKey6'];
|
||||||
|
final transferCount = json['transferCount'];
|
||||||
|
final lastTransferredAtMillis = json['lastTransferredAtMillis'];
|
||||||
|
if (requesterKey6 is! String ||
|
||||||
|
transferCount is! int ||
|
||||||
|
lastTransferredAtMillis is! int) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MessageTransferDownloader(
|
||||||
|
requesterKey6: requesterKey6,
|
||||||
|
requesterName: json['requesterName'] as String?,
|
||||||
|
transferCount: transferCount,
|
||||||
|
lastTransferredAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
lastTransferredAtMillis,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageTransferDetails {
|
||||||
|
final int totalTransfers;
|
||||||
|
final List<MessageTransferDownloader> downloaders;
|
||||||
|
|
||||||
|
const MessageTransferDetails({
|
||||||
|
required this.totalTransfers,
|
||||||
|
required this.downloaders,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MessageTransferDetails.empty()
|
||||||
|
: totalTransfers = 0,
|
||||||
|
downloaders = const [];
|
||||||
|
|
||||||
|
MessageTransferDetails registerTransfer({
|
||||||
|
required String requesterKey6,
|
||||||
|
String? requesterName,
|
||||||
|
DateTime? transferredAt,
|
||||||
|
}) {
|
||||||
|
final eventAt = transferredAt ?? DateTime.now();
|
||||||
|
final normalizedName = requesterName?.trim();
|
||||||
|
final updatedDownloaders = List<MessageTransferDownloader>.from(
|
||||||
|
downloaders,
|
||||||
|
);
|
||||||
|
final index = updatedDownloaders.indexWhere(
|
||||||
|
(entry) => entry.requesterKey6 == requesterKey6,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (index == -1) {
|
||||||
|
updatedDownloaders.add(
|
||||||
|
MessageTransferDownloader(
|
||||||
|
requesterKey6: requesterKey6,
|
||||||
|
requesterName: normalizedName?.isEmpty ?? true
|
||||||
|
? null
|
||||||
|
: normalizedName,
|
||||||
|
transferCount: 1,
|
||||||
|
lastTransferredAt: eventAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final existing = updatedDownloaders[index];
|
||||||
|
updatedDownloaders[index] = existing.copyWith(
|
||||||
|
requesterName: normalizedName?.isEmpty ?? true
|
||||||
|
? existing.requesterName
|
||||||
|
: normalizedName,
|
||||||
|
transferCount: existing.transferCount + 1,
|
||||||
|
lastTransferredAt: eventAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedDownloaders.sort(
|
||||||
|
(a, b) => b.lastTransferredAt.compareTo(a.lastTransferredAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
return MessageTransferDetails(
|
||||||
|
totalTransfers: totalTransfers + 1,
|
||||||
|
downloaders: updatedDownloaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'totalTransfers': totalTransfers,
|
||||||
|
'downloaders': downloaders.map((entry) => entry.toJson()).toList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageTransferDetails? fromJson(Map<String, dynamic> json) {
|
||||||
|
final totalTransfers = json['totalTransfers'];
|
||||||
|
if (totalTransfers is! int) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawDownloaders = json['downloaders'] as List<dynamic>? ?? const [];
|
||||||
|
final downloaders = rawDownloaders
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(MessageTransferDownloader.fromJson)
|
||||||
|
.whereType<MessageTransferDownloader>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return MessageTransferDetails(
|
||||||
|
totalTransfers: totalTransfers,
|
||||||
|
downloaders: downloaders,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/models/path_selection.dart
Normal file
65
lib/models/path_selection.dart
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
enum PathSelectionMode { directCurrent, directHistorical, flood, nearestRouter }
|
||||||
|
|
||||||
|
class PathSelection {
|
||||||
|
final PathSelectionMode mode;
|
||||||
|
final Uint8List pathBytes;
|
||||||
|
final int hopCount;
|
||||||
|
final int hashSize;
|
||||||
|
final String? relayName;
|
||||||
|
final String? relayKey6;
|
||||||
|
|
||||||
|
const PathSelection({
|
||||||
|
required this.mode,
|
||||||
|
required this.pathBytes,
|
||||||
|
required this.hopCount,
|
||||||
|
required this.hashSize,
|
||||||
|
this.relayName,
|
||||||
|
this.relayKey6,
|
||||||
|
});
|
||||||
|
|
||||||
|
PathSelection.flood()
|
||||||
|
: mode = PathSelectionMode.flood,
|
||||||
|
pathBytes = Uint8List(0),
|
||||||
|
hopCount = -1,
|
||||||
|
hashSize = 1,
|
||||||
|
relayName = null,
|
||||||
|
relayKey6 = null;
|
||||||
|
|
||||||
|
bool get usesFlood => mode == PathSelectionMode.flood;
|
||||||
|
bool get hasDirectPath => !usesFlood && pathBytes.isNotEmpty && hopCount > 0;
|
||||||
|
|
||||||
|
String get canonicalPath {
|
||||||
|
if (!hasDirectPath) return '';
|
||||||
|
final hops = <String>[];
|
||||||
|
for (var index = 0; index < pathBytes.length; index += hashSize) {
|
||||||
|
hops.add(
|
||||||
|
pathBytes
|
||||||
|
.sublist(index, index + hashSize)
|
||||||
|
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
|
||||||
|
.join()
|
||||||
|
.toUpperCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return hops.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
PathSelection copyWith({
|
||||||
|
PathSelectionMode? mode,
|
||||||
|
Uint8List? pathBytes,
|
||||||
|
int? hopCount,
|
||||||
|
int? hashSize,
|
||||||
|
String? relayName,
|
||||||
|
String? relayKey6,
|
||||||
|
}) {
|
||||||
|
return PathSelection(
|
||||||
|
mode: mode ?? this.mode,
|
||||||
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
|
hopCount: hopCount ?? this.hopCount,
|
||||||
|
hashSize: hashSize ?? this.hashSize,
|
||||||
|
relayName: relayName ?? this.relayName,
|
||||||
|
relayKey6: relayKey6 ?? this.relayKey6,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:typed_data';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import 'map_coordinate_space.dart';
|
||||||
import '../services/sar_template_service.dart';
|
import '../services/sar_template_service.dart';
|
||||||
|
|
||||||
/// SAR (Search & Rescue) marker types
|
/// SAR (Search & Rescue) marker types
|
||||||
@@ -78,6 +79,8 @@ class SarMarker {
|
|||||||
final String? notes;
|
final String? notes;
|
||||||
final String? customEmoji; // For custom SAR markers not in predefined types
|
final String? customEmoji; // For custom SAR markers not in predefined types
|
||||||
final int? colorIndex; // Color index (0-7) from standard palette
|
final int? colorIndex; // Color index (0-7) from standard palette
|
||||||
|
final MapCoordinateSpace coordinateSpace;
|
||||||
|
final String? mapId;
|
||||||
|
|
||||||
SarMarker({
|
SarMarker({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -89,8 +92,13 @@ class SarMarker {
|
|||||||
this.notes,
|
this.notes,
|
||||||
this.customEmoji,
|
this.customEmoji,
|
||||||
this.colorIndex,
|
this.colorIndex,
|
||||||
|
this.coordinateSpace = MapCoordinateSpace.geo,
|
||||||
|
this.mapId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get isCustomMapMarker =>
|
||||||
|
coordinateSpace == MapCoordinateSpace.customMap && mapId != null;
|
||||||
|
|
||||||
/// Get sender public key as hex string (short)
|
/// Get sender public key as hex string (short)
|
||||||
String? get senderKeyShort {
|
String? get senderKeyShort {
|
||||||
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
|
if (senderPublicKey == null || senderPublicKey!.length < 8) return null;
|
||||||
@@ -167,6 +175,8 @@ class SarMarker {
|
|||||||
String? notes,
|
String? notes,
|
||||||
String? customEmoji,
|
String? customEmoji,
|
||||||
int? colorIndex,
|
int? colorIndex,
|
||||||
|
MapCoordinateSpace? coordinateSpace,
|
||||||
|
String? mapId,
|
||||||
}) {
|
}) {
|
||||||
return SarMarker(
|
return SarMarker(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
@@ -178,6 +188,8 @@ class SarMarker {
|
|||||||
notes: notes ?? this.notes,
|
notes: notes ?? this.notes,
|
||||||
customEmoji: customEmoji ?? this.customEmoji,
|
customEmoji: customEmoji ?? this.customEmoji,
|
||||||
colorIndex: colorIndex ?? this.colorIndex,
|
colorIndex: colorIndex ?? this.colorIndex,
|
||||||
|
coordinateSpace: coordinateSpace ?? this.coordinateSpace,
|
||||||
|
mapId: mapId ?? this.mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
/// SSE Server Configuration Model
|
|
||||||
///
|
|
||||||
/// Configuration for the SSE (Server-Sent Events) web server that enables
|
|
||||||
/// multiple app instances to share a single MeshCore BLE device.
|
|
||||||
class SseServerConfig {
|
|
||||||
/// Server bind address (e.g., "0.0.0.0" for all interfaces, "127.0.0.1" for localhost)
|
|
||||||
final String host;
|
|
||||||
|
|
||||||
/// Server port (default: 12929)
|
|
||||||
final int port;
|
|
||||||
|
|
||||||
/// Whether the SSE server is enabled
|
|
||||||
final bool enabled;
|
|
||||||
|
|
||||||
/// Optional authentication token for basic security
|
|
||||||
/// Clients must include this token in Authorization header
|
|
||||||
final String? authToken;
|
|
||||||
|
|
||||||
const SseServerConfig({
|
|
||||||
this.host = '0.0.0.0',
|
|
||||||
this.port = 12929,
|
|
||||||
this.enabled = false,
|
|
||||||
this.authToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Create a copy with updated fields
|
|
||||||
SseServerConfig copyWith({
|
|
||||||
String? host,
|
|
||||||
int? port,
|
|
||||||
bool? enabled,
|
|
||||||
String? authToken,
|
|
||||||
}) {
|
|
||||||
return SseServerConfig(
|
|
||||||
host: host ?? this.host,
|
|
||||||
port: port ?? this.port,
|
|
||||||
enabled: enabled ?? this.enabled,
|
|
||||||
authToken: authToken ?? this.authToken,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get server URL for clients to connect to
|
|
||||||
String getServerUrl({String? ipAddress}) {
|
|
||||||
final ip = ipAddress ?? host;
|
|
||||||
return 'http://$ip:$port';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert to JSON for persistence
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'host': host,
|
|
||||||
'port': port,
|
|
||||||
'enabled': enabled,
|
|
||||||
'authToken': authToken,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create from JSON
|
|
||||||
factory SseServerConfig.fromJson(Map<String, dynamic> json) {
|
|
||||||
return SseServerConfig(
|
|
||||||
host: json['host'] as String? ?? '0.0.0.0',
|
|
||||||
port: json['port'] as int? ?? 12929,
|
|
||||||
enabled: json['enabled'] as bool? ?? false,
|
|
||||||
authToken: json['authToken'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() {
|
|
||||||
return 'SseServerConfig(host: $host, port: $port, enabled: $enabled, hasAuth: ${authToken != null})';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,8 @@ class ChannelsProvider with ChangeNotifier {
|
|||||||
int _selectedChannelIndex = 0; // Default to public channel
|
int _selectedChannelIndex = 0; // Default to public channel
|
||||||
|
|
||||||
/// Get all channels
|
/// Get all channels
|
||||||
List<Channel> get channels => _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
List<Channel> get channels =>
|
||||||
|
_channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
|
||||||
|
|
||||||
/// Get a specific channel by index
|
/// Get a specific channel by index
|
||||||
Channel? getChannel(int index) => _channels[index];
|
Channel? getChannel(int index) => _channels[index];
|
||||||
@@ -28,6 +29,16 @@ class ChannelsProvider with ChangeNotifier {
|
|||||||
return index == 0 ? 'Public' : 'Channel $index';
|
return index == 0 ? 'Public' : 'Channel $index';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Channel> getChannelsByHashByte(int hashByte) {
|
||||||
|
return _channels.values.where((channel) => channel.hashByte == hashByte).toList()
|
||||||
|
..sort((a, b) => a.index.compareTo(b.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
String? getUniqueChannelDisplayNameByHashByte(int hashByte) {
|
||||||
|
final matches = getChannelsByHashByte(hashByte);
|
||||||
|
return matches.length == 1 ? matches.single.displayName : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Add or update a channel
|
/// Add or update a channel
|
||||||
void addOrUpdateChannel({
|
void addOrUpdateChannel({
|
||||||
required int index,
|
required int index,
|
||||||
@@ -54,12 +65,12 @@ class ChannelsProvider with ChangeNotifier {
|
|||||||
void removeChannel(int index) {
|
void removeChannel(int index) {
|
||||||
if (_channels.containsKey(index)) {
|
if (_channels.containsKey(index)) {
|
||||||
_channels.remove(index);
|
_channels.remove(index);
|
||||||
|
|
||||||
// If the deleted channel was selected, switch to public channel
|
// If the deleted channel was selected, switch to public channel
|
||||||
if (_selectedChannelIndex == index) {
|
if (_selectedChannelIndex == index) {
|
||||||
_selectedChannelIndex = 0;
|
_selectedChannelIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,6 +107,13 @@ class ChannelsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear runtime channel state before a live device sync begins.
|
||||||
|
void prepareForDeviceSync() {
|
||||||
|
_channels.clear();
|
||||||
|
_selectedChannelIndex = 0;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if channels have been loaded
|
/// Check if channels have been loaded
|
||||||
bool get hasChannels => _channels.isNotEmpty;
|
bool get hasChannels => _channels.isNotEmpty;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
|
import '../models/map_coordinate_space.dart';
|
||||||
|
import '../services/profiles_feature_service.dart';
|
||||||
import '../utils/drawing_message_parser.dart';
|
import '../utils/drawing_message_parser.dart';
|
||||||
|
|
||||||
/// Drawing mode state
|
/// Drawing mode state
|
||||||
@@ -11,6 +14,8 @@ enum DrawingMode { none, line, rectangle, measure }
|
|||||||
/// Provider for managing map drawings
|
/// Provider for managing map drawings
|
||||||
class DrawingProvider with ChangeNotifier {
|
class DrawingProvider with ChangeNotifier {
|
||||||
static const String _storageKey = 'map_drawings';
|
static const String _storageKey = 'map_drawings';
|
||||||
|
static const String _showReceivedDrawingsKey = 'map_show_received_drawings';
|
||||||
|
static const String _showSarMarkersKey = 'map_show_sar_markers';
|
||||||
|
|
||||||
// Drawing state
|
// Drawing state
|
||||||
DrawingMode _drawingMode = DrawingMode.none;
|
DrawingMode _drawingMode = DrawingMode.none;
|
||||||
@@ -20,6 +25,7 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
|
|
||||||
// Completed drawings
|
// Completed drawings
|
||||||
final List<MapDrawing> _drawings = [];
|
final List<MapDrawing> _drawings = [];
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
// In-progress drawing
|
// In-progress drawing
|
||||||
MapDrawing? _currentDrawing;
|
MapDrawing? _currentDrawing;
|
||||||
@@ -30,12 +36,18 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
LatLng? _measurementPoint1;
|
LatLng? _measurementPoint1;
|
||||||
LatLng? _measurementPoint2;
|
LatLng? _measurementPoint2;
|
||||||
double? _measuredDistance; // in meters
|
double? _measuredDistance; // in meters
|
||||||
|
MapCoordinateSpace _activeCoordinateSpace = MapCoordinateSpace.geo;
|
||||||
|
String? _activeMapId;
|
||||||
|
double? _activeMetersPerPixel;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
DrawingMode get drawingMode => _drawingMode;
|
DrawingMode get drawingMode => _drawingMode;
|
||||||
Color get selectedColor => _selectedColor;
|
Color get selectedColor => _selectedColor;
|
||||||
bool get showReceivedDrawings => _showReceivedDrawings;
|
bool get showReceivedDrawings => _showReceivedDrawings;
|
||||||
bool get showSarMarkers => _showSarMarkers;
|
bool get showSarMarkers => _showSarMarkers;
|
||||||
|
MapCoordinateSpace get activeCoordinateSpace => _activeCoordinateSpace;
|
||||||
|
String? get activeMapId => _activeMapId;
|
||||||
|
double? get activeMetersPerPixel => _activeMetersPerPixel;
|
||||||
List<MapDrawing> get drawings {
|
List<MapDrawing> get drawings {
|
||||||
// Filter out hidden drawings first
|
// Filter out hidden drawings first
|
||||||
var visibleDrawings = _drawings.where((d) => !d.isHidden);
|
var visibleDrawings = _drawings.where((d) => !d.isHidden);
|
||||||
@@ -45,19 +57,62 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
visibleDrawings = visibleDrawings.where((d) => !d.isReceived);
|
visibleDrawings = visibleDrawings.where((d) => !d.isReceived);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
visibleDrawings = visibleDrawings.where((drawing) {
|
||||||
|
if (drawing.coordinateSpace != _activeCoordinateSpace) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||||
|
return drawing.mapId != null && drawing.mapId == _activeMapId;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
return List.unmodifiable(visibleDrawings.toList());
|
return List.unmodifiable(visibleDrawings.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
MapDrawing? get currentDrawing => _currentDrawing;
|
MapDrawing? get currentDrawing => _currentDrawing;
|
||||||
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
List<LatLng> get currentLinePoints => List.unmodifiable(_currentLinePoints);
|
||||||
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
LatLng? get rectangleStartPoint => _rectangleStartPoint;
|
||||||
bool get isDrawing => _drawingMode != DrawingMode.none;
|
bool get isDrawing => _drawingMode != DrawingMode.none;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
LatLng? get measurementPoint1 => _measurementPoint1;
|
LatLng? get measurementPoint1 => _measurementPoint1;
|
||||||
LatLng? get measurementPoint2 => _measurementPoint2;
|
LatLng? get measurementPoint2 => _measurementPoint2;
|
||||||
double? get measuredDistance => _measuredDistance;
|
double? get measuredDistance => _measuredDistance;
|
||||||
|
|
||||||
/// Initialize and load saved drawings
|
/// Initialize and load saved drawings
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
|
await _loadPreferences();
|
||||||
await _loadDrawings();
|
await _loadDrawings();
|
||||||
|
_isInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reloadProfileScopedState() async {
|
||||||
|
await _loadPreferences();
|
||||||
|
await _loadDrawings();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMapContext({
|
||||||
|
required MapCoordinateSpace coordinateSpace,
|
||||||
|
String? mapId,
|
||||||
|
double? metersPerPixel,
|
||||||
|
}) {
|
||||||
|
final changed =
|
||||||
|
coordinateSpace != _activeCoordinateSpace ||
|
||||||
|
mapId != _activeMapId ||
|
||||||
|
metersPerPixel != _activeMetersPerPixel;
|
||||||
|
_activeCoordinateSpace = coordinateSpace;
|
||||||
|
_activeMapId = mapId;
|
||||||
|
_activeMetersPerPixel = metersPerPixel;
|
||||||
|
if (_measurementPoint1 != null && _measurementPoint2 != null) {
|
||||||
|
_measuredDistance = _calculateDistance(
|
||||||
|
_measurementPoint1!,
|
||||||
|
_measurementPoint2!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set drawing mode
|
/// Set drawing mode
|
||||||
@@ -77,15 +132,34 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle visibility of received drawings
|
/// Toggle visibility of received drawings
|
||||||
void toggleReceivedDrawings() {
|
Future<void> toggleReceivedDrawings() async {
|
||||||
_showReceivedDrawings = !_showReceivedDrawings;
|
_showReceivedDrawings = !_showReceivedDrawings;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _savePreferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle visibility of SAR markers
|
/// Toggle visibility of SAR markers
|
||||||
void toggleSarMarkers() {
|
Future<void> toggleSarMarkers() async {
|
||||||
_showSarMarkers = !_showSarMarkers;
|
_showSarMarkers = !_showSarMarkers;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _savePreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_showReceivedDrawings =
|
||||||
|
prefs.getBool(_scopedKey(_showReceivedDrawingsKey)) ?? true;
|
||||||
|
_showSarMarkers = prefs.getBool(_scopedKey(_showSarMarkersKey)) ?? true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _savePreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(
|
||||||
|
_scopedKey(_showReceivedDrawingsKey),
|
||||||
|
_showReceivedDrawings,
|
||||||
|
);
|
||||||
|
await prefs.setBool(_scopedKey(_showSarMarkersKey), _showSarMarkers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start drawing a line
|
/// Start drawing a line
|
||||||
@@ -116,6 +190,10 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
color: _selectedColor,
|
color: _selectedColor,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
points: List.from(_currentLinePoints),
|
points: List.from(_currentLinePoints),
|
||||||
|
coordinateSpace: _activeCoordinateSpace,
|
||||||
|
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||||
|
? _activeMapId
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
_drawings.add(drawing);
|
_drawings.add(drawing);
|
||||||
@@ -159,6 +237,10 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
? _rectangleStartPoint!.longitude
|
? _rectangleStartPoint!.longitude
|
||||||
: endPoint.longitude,
|
: endPoint.longitude,
|
||||||
),
|
),
|
||||||
|
coordinateSpace: _activeCoordinateSpace,
|
||||||
|
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||||
|
? _activeMapId
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -195,6 +277,10 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
topLeft: topLeft,
|
topLeft: topLeft,
|
||||||
bottomRight: bottomRight,
|
bottomRight: bottomRight,
|
||||||
|
coordinateSpace: _activeCoordinateSpace,
|
||||||
|
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||||
|
? _activeMapId
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
_drawings.add(drawing);
|
_drawings.add(drawing);
|
||||||
@@ -216,7 +302,9 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Set second measurement point and calculate distance
|
/// Set second measurement point and calculate distance
|
||||||
void setMeasurementPoint2(LatLng point) {
|
void setMeasurementPoint2(LatLng point) {
|
||||||
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) return;
|
if (_drawingMode != DrawingMode.measure || _measurementPoint1 == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_measurementPoint2 = point;
|
_measurementPoint2 = point;
|
||||||
_measuredDistance = _calculateDistance(_measurementPoint1!, point);
|
_measuredDistance = _calculateDistance(_measurementPoint1!, point);
|
||||||
@@ -225,6 +313,13 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
|
|
||||||
/// Calculate distance between two points using Haversine formula
|
/// Calculate distance between two points using Haversine formula
|
||||||
double _calculateDistance(LatLng point1, LatLng point2) {
|
double _calculateDistance(LatLng point1, LatLng point2) {
|
||||||
|
if (_activeCoordinateSpace == MapCoordinateSpace.customMap &&
|
||||||
|
_activeMetersPerPixel != null) {
|
||||||
|
final dy = point2.latitude - point1.latitude;
|
||||||
|
final dx = point2.longitude - point1.longitude;
|
||||||
|
final pixelDistance = math.sqrt((dx * dx) + (dy * dy));
|
||||||
|
return pixelDistance * _activeMetersPerPixel!;
|
||||||
|
}
|
||||||
const Distance distance = Distance();
|
const Distance distance = Distance();
|
||||||
return distance.as(LengthUnit.Meter, point1, point2);
|
return distance.as(LengthUnit.Meter, point1, point2);
|
||||||
}
|
}
|
||||||
@@ -281,7 +376,7 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonList = _drawings.map((d) => d.toJson()).toList();
|
final jsonList = _drawings.map((d) => d.toJson()).toList();
|
||||||
final jsonString = jsonEncode(jsonList);
|
final jsonString = jsonEncode(jsonList);
|
||||||
await prefs.setString(_storageKey, jsonString);
|
await prefs.setString(_scopedKey(_storageKey), jsonString);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error saving drawings: $e');
|
debugPrint('Error saving drawings: $e');
|
||||||
}
|
}
|
||||||
@@ -291,8 +386,11 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
Future<void> _loadDrawings() async {
|
Future<void> _loadDrawings() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_storageKey);
|
final jsonString = prefs.getString(_scopedKey(_storageKey));
|
||||||
if (jsonString == null) return;
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
_drawings.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
_drawings.clear();
|
_drawings.clear();
|
||||||
@@ -318,6 +416,10 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
color: _selectedColor,
|
color: _selectedColor,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
points: _currentLinePoints,
|
points: _currentLinePoints,
|
||||||
|
coordinateSpace: _activeCoordinateSpace,
|
||||||
|
mapId: _activeCoordinateSpace == MapCoordinateSpace.customMap
|
||||||
|
? _activeMapId
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
} else if (_drawingMode == DrawingMode.rectangle &&
|
} else if (_drawingMode == DrawingMode.rectangle &&
|
||||||
_currentDrawing != null) {
|
_currentDrawing != null) {
|
||||||
@@ -355,6 +457,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: drawing.isShared,
|
isShared: drawing.isShared,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: drawing.isHidden,
|
isHidden: drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
} else if (drawing is RectangleDrawing) {
|
} else if (drawing is RectangleDrawing) {
|
||||||
return RectangleDrawing(
|
return RectangleDrawing(
|
||||||
@@ -369,6 +473,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: drawing.isShared,
|
isShared: drawing.isShared,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: drawing.isHidden,
|
isHidden: drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return drawing;
|
return drawing;
|
||||||
@@ -383,9 +489,27 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> exportDrawingsJson() {
|
||||||
|
return _drawings.map((drawing) => drawing.toJson()).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replaceDrawingsFromJson(
|
||||||
|
List<Map<String, dynamic>> jsonList,
|
||||||
|
) async {
|
||||||
|
_drawings
|
||||||
|
..clear()
|
||||||
|
..addAll(jsonList.map(MapDrawing.fromJson).whereType<MapDrawing>());
|
||||||
|
await _saveDrawings();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _scopedKey(String baseKey) {
|
||||||
|
return ProfileStorageScope.scopedKey(baseKey);
|
||||||
|
}
|
||||||
|
|
||||||
/// Get all unshared drawings (local drawings not yet sent)
|
/// Get all unshared drawings (local drawings not yet sent)
|
||||||
List<MapDrawing> getUnsharedDrawings() {
|
List<MapDrawing> getUnsharedDrawings() {
|
||||||
return _drawings.where((d) => !d.isShared && !d.isReceived).toList();
|
return drawings.where((d) => !d.isShared && !d.isReceived).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a drawing as shared
|
/// Mark a drawing as shared
|
||||||
@@ -407,6 +531,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: true,
|
isShared: true,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: drawing.isHidden,
|
isHidden: drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
} else if (drawing is RectangleDrawing) {
|
} else if (drawing is RectangleDrawing) {
|
||||||
_drawings[index] = RectangleDrawing(
|
_drawings[index] = RectangleDrawing(
|
||||||
@@ -421,6 +547,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: true,
|
isShared: true,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: drawing.isHidden,
|
isHidden: drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +576,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: drawing.isShared,
|
isShared: drawing.isShared,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: !drawing.isHidden,
|
isHidden: !drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
} else if (drawing is RectangleDrawing) {
|
} else if (drawing is RectangleDrawing) {
|
||||||
_drawings[index] = RectangleDrawing(
|
_drawings[index] = RectangleDrawing(
|
||||||
@@ -462,6 +592,8 @@ class DrawingProvider with ChangeNotifier {
|
|||||||
isShared: drawing.isShared,
|
isShared: drawing.isShared,
|
||||||
isSent: drawing.isSent,
|
isSent: drawing.isSent,
|
||||||
isHidden: !drawing.isHidden,
|
isHidden: !drawing.isHidden,
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal file
43
lib/providers/helpers/fragment_ack_wait_registry.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
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 +19,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 = {};
|
||||||
|
|
||||||
@@ -25,6 +31,9 @@ class MessageDeliveryTracker {
|
|||||||
/// Map of ACK tag to timestamp for timeout cleanup
|
/// Map of ACK tag to timestamp for timeout cleanup
|
||||||
final Map<int, DateTime> _ackTagTimestamps = {};
|
final Map<int, DateTime> _ackTagTimestamps = {};
|
||||||
|
|
||||||
|
/// Completer signalled when a pending ACK slot is freed (delivery or removal).
|
||||||
|
Completer<void>? _slotFreedCompleter;
|
||||||
|
|
||||||
/// Track a pending message ID before sending
|
/// Track a pending message ID before sending
|
||||||
///
|
///
|
||||||
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
/// This is called BEFORE sending the message. When RESP_CODE_SENT
|
||||||
@@ -33,6 +42,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 +61,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 +102,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.
|
||||||
@@ -75,6 +116,7 @@ class MessageDeliveryTracker {
|
|||||||
_messageIdToAckTag.remove(messageId);
|
_messageIdToAckTag.remove(messageId);
|
||||||
}
|
}
|
||||||
_ackTagTimestamps.remove(ackCode);
|
_ackTagTimestamps.remove(ackCode);
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove ACK tag mapping by message ID
|
/// Remove ACK tag mapping by message ID
|
||||||
@@ -85,6 +127,18 @@ class MessageDeliveryTracker {
|
|||||||
if (ackTag != null) {
|
if (ackTag != null) {
|
||||||
_ackTagToMessageId.remove(ackTag);
|
_ackTagToMessageId.remove(ackTag);
|
||||||
_ackTagTimestamps.remove(ackTag);
|
_ackTagTimestamps.remove(ackTag);
|
||||||
|
_notifySlotFreed();
|
||||||
|
}
|
||||||
|
_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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,9 +168,11 @@ 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();
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get count of pending ACK tags
|
/// Get count of pending ACK tags
|
||||||
@@ -130,12 +186,31 @@ class MessageDeliveryTracker {
|
|||||||
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
/// Returns true if >= 7 pending ACKs (stay under firmware limit of 8)
|
||||||
bool get shouldRateLimit => pendingCount >= 7;
|
bool get shouldRateLimit => pendingCount >= 7;
|
||||||
|
|
||||||
|
/// Wait until a pending ACK slot is freed, or [timeout] elapses.
|
||||||
|
///
|
||||||
|
/// Returns immediately if not at the rate limit.
|
||||||
|
Future<void> waitForSlot({
|
||||||
|
Duration timeout = const Duration(milliseconds: 500),
|
||||||
|
}) async {
|
||||||
|
if (!shouldRateLimit) return;
|
||||||
|
_slotFreedCompleter ??= Completer<void>();
|
||||||
|
await _slotFreedCompleter!.future.timeout(
|
||||||
|
timeout,
|
||||||
|
onTimeout: () {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifySlotFreed() {
|
||||||
|
if (_slotFreedCompleter != null && !_slotFreedCompleter!.isCompleted) {
|
||||||
|
_slotFreedCompleter!.complete();
|
||||||
|
}
|
||||||
|
_slotFreedCompleter = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get oldest pending ACK timestamp (for debugging)
|
/// 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 +220,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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,104 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
|
|
||||||
/// Manages message retry state and logic
|
/// Manages message retry state and logic
|
||||||
///
|
///
|
||||||
/// This helper class centralizes retry logic for direct messages, implementing
|
/// This helper class centralizes retry logic for direct messages.
|
||||||
/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts
|
|
||||||
/// with learned routing paths.
|
|
||||||
///
|
///
|
||||||
/// IMPORTANT: Based on MeshCore firmware analysis:
|
/// IMPORTANT: Based on MeshCore firmware analysis:
|
||||||
/// - Firmware calculates timeout based on path length and airtime
|
/// - Firmware calculates timeout based on path length and airtime
|
||||||
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
/// - Direct mode: ~(path_len * airtime * 2) + margin
|
||||||
/// - Flood mode: ~10-30 seconds for multi-hop
|
/// - Flood mode: ~10-30 seconds for multi-hop
|
||||||
/// - Our timeouts (4s, 8s, 12s) are conservative for direct paths
|
/// - Our retry delays (1s, 2s, 4s, 8s) are app-level backoff timers
|
||||||
/// - Firmware does NOT automatically retry - app must implement
|
/// - Firmware does NOT automatically retry - app must implement
|
||||||
class MessageRetryManager {
|
class MessageRetryManager {
|
||||||
// Track retry state for each message ID
|
// Track retry state for each message ID
|
||||||
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
|
/// Max retry attempts when the contact has a known path.
|
||||||
// These are app-level timeouts, separate from firmware's suggested timeout
|
/// Sequence: 2 direct attempts, flood, then last successful route.
|
||||||
// Firmware timeout is for ACK arrival, these are for retry attempts
|
static const int maxRetryAttemptsWithPath = 3;
|
||||||
static const List<int> _timeouts = [4000, 8000, 12000];
|
|
||||||
|
|
||||||
/// Get timeout for a specific retry attempt (0-2)
|
/// No retries for flood-only contacts (no known path).
|
||||||
/// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2
|
/// Value 0 means: don't retry at all, go straight to fallback/fail.
|
||||||
int getTimeoutForAttempt(int attempt) {
|
static const int maxRetryAttemptsFloodOnly = 0;
|
||||||
if (attempt < 0 || attempt >= _timeouts.length) {
|
|
||||||
return _timeouts.last; // Default to last timeout if out of range
|
@Deprecated('Use maxRetryAttemptsForContact instead')
|
||||||
|
static const int maxRetryAttempts = maxRetryAttemptsWithPath;
|
||||||
|
|
||||||
|
// Retry backoff values in milliseconds.
|
||||||
|
static const List<int> _retryDelays = [1000, 2000, 4000, 8000, 8000];
|
||||||
|
static const int _defaultLoRaSf = 10;
|
||||||
|
static const int _defaultLoRaCr = 5;
|
||||||
|
static const int _defaultLoRaBwHz = 250000;
|
||||||
|
static const int _defaultLoRaPreambleSymbols = 8;
|
||||||
|
static const int _defaultLoRaCrcEnabled = 1;
|
||||||
|
static const int _defaultLoRaExplicitHeader = 1;
|
||||||
|
|
||||||
|
/// Get backoff delay for the next retry attempt.
|
||||||
|
int getDelayForAttempt(int attempt) {
|
||||||
|
if (attempt < 0 || attempt >= _retryDelays.length) {
|
||||||
|
return _retryDelays.last;
|
||||||
}
|
}
|
||||||
return _timeouts[attempt];
|
return _retryDelays[attempt];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a message is eligible for retry
|
static final math.Random _rng = math.Random();
|
||||||
|
|
||||||
|
/// Calculate a delivery-ACK timeout with random jitter.
|
||||||
///
|
///
|
||||||
/// Returns true if:
|
/// Matches the official MeshCore app: `suggestedTimeout + random(1-8s)`.
|
||||||
/// - The message has retryAttempt < 3
|
/// The jitter prevents collision when multiple messages are in flight.
|
||||||
/// - The contact has a learned path (contact.hasPath == true)
|
int calculateAckTimeoutMs({
|
||||||
/// - The message hasn't used flood fallback yet
|
required String text,
|
||||||
///
|
required Contact? contact,
|
||||||
/// Messages to contacts without paths should NOT retry (flood mode already broadcasts)
|
int? suggestedTimeoutMs,
|
||||||
|
}) {
|
||||||
|
int baseTimeout;
|
||||||
|
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
||||||
|
baseTimeout = suggestedTimeoutMs;
|
||||||
|
} else {
|
||||||
|
final payloadBytes = utf8.encode(text).length;
|
||||||
|
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
||||||
|
final hopCount = contact?.routeHasPath == true
|
||||||
|
? contact!.routeHopCount
|
||||||
|
: -1;
|
||||||
|
|
||||||
|
if (hopCount < 0) {
|
||||||
|
baseTimeout = ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
||||||
|
} else {
|
||||||
|
baseTimeout = ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add random jitter: 500-2000ms to avoid collision
|
||||||
|
final jitterMs = 500 + _rng.nextInt(1501);
|
||||||
|
return baseTimeout + jitterMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Max attempts for a given contact based on whether it has a known path.
|
||||||
|
static int maxRetryAttemptsForContact(Contact? contact) {
|
||||||
|
final hasPath = contact?.routeHasPath ?? false;
|
||||||
|
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
|
||||||
|
}
|
||||||
|
|
||||||
bool canRetry(Message message, Contact contact) {
|
bool canRetry(Message message, Contact contact) {
|
||||||
// Never retry if already tried flood mode
|
return message.retryAttempt < maxRetryAttemptsForContact(contact);
|
||||||
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
|
/// Whether this is the last retry attempt.
|
||||||
|
/// When true, the caller should reset the path to force flood mode.
|
||||||
///
|
///
|
||||||
/// Returns true if:
|
/// Note: called AFTER retryAttempt has been incremented, so we compare
|
||||||
/// - Message has exhausted all 3 retry attempts with direct mode
|
/// directly against maxAttempts (not +1).
|
||||||
/// - Contact HAS a learned path (so direct mode was used)
|
bool isLastAttempt(Message message, Contact contact) {
|
||||||
/// - Hasn't already used flood fallback
|
final maxAttempts = maxRetryAttemptsForContact(contact);
|
||||||
///
|
return maxAttempts > 1 && message.retryAttempt >= maxAttempts;
|
||||||
/// 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 +117,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 +129,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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
81
lib/providers/helpers/raw_session_retransmit.dart
Normal file
81
lib/providers/helpers/raw_session_retransmit.dart
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../../models/contact.dart';
|
||||||
|
|
||||||
|
typedef RawPacketSender =
|
||||||
|
Future<void> Function({
|
||||||
|
required Uint8List contactPath,
|
||||||
|
required int contactPathLen,
|
||||||
|
required Uint8List payload,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<bool> serveCachedSessionFragments<T>({
|
||||||
|
required String providerLabel,
|
||||||
|
required String sessionId,
|
||||||
|
required Contact requester,
|
||||||
|
required List<T> fragments,
|
||||||
|
required int maxDirectPayloadHops,
|
||||||
|
required int Function(T fragment) indexOf,
|
||||||
|
required Uint8List Function(T fragment) encodeBinary,
|
||||||
|
required RawPacketSender? sendRawPacket,
|
||||||
|
Set<int>? requestedIndices,
|
||||||
|
}) async {
|
||||||
|
if (fragments.isEmpty) {
|
||||||
|
debugPrint('⚠️ [$providerLabel] No cached fragments for $sessionId');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (sendRawPacket == null) {
|
||||||
|
debugPrint('⚠️ [$providerLabel] sendRawPacketCallback not set');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!requester.routeHasPath) {
|
||||||
|
debugPrint('⚠️ [$providerLabel] ${requester.advName} has no direct path');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (requester.routeHopCount > maxDirectPayloadHops) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [$providerLabel] ${requester.advName} is too far: ${requester.routeHopCount} hops (max $maxDirectPayloadHops)',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (requester.outPath.isEmpty) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var servedCount = 0;
|
||||||
|
for (final fragment in fragments) {
|
||||||
|
final index = indexOf(fragment);
|
||||||
|
if (index < 0) {
|
||||||
|
debugPrint('⚠️ [$providerLabel] Invalid fragment index $index');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (requestedIndices != null && !requestedIndices.contains(index)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sendRawPacket(
|
||||||
|
contactPath: requester.outPath,
|
||||||
|
contactPathLen: requester.routeEncodedPathLen,
|
||||||
|
payload: encodeBinary(fragment),
|
||||||
|
);
|
||||||
|
servedCount++;
|
||||||
|
} catch (e, st) {
|
||||||
|
debugPrint(
|
||||||
|
'❌ [$providerLabel] Serve error for $sessionId#$index: $e\n$st',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (servedCount == 0) {
|
||||||
|
debugPrint(
|
||||||
|
'⚠️ [$providerLabel] No fragments matched request for $sessionId',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
debugPrint('✅ [$providerLabel] Served $servedCount fragments for $sessionId');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
57
lib/providers/helpers/session_metadata_restore.dart
Normal file
57
lib/providers/helpers/session_metadata_restore.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ 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/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../services/profiles_feature_service.dart';
|
||||||
|
import 'helpers/raw_session_retransmit.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
|
|
||||||
/// Reassembly state for one incoming image session.
|
/// Reassembly state for one incoming image session.
|
||||||
@@ -13,6 +15,8 @@ class ImageSession {
|
|||||||
final int width;
|
final int width;
|
||||||
final int height;
|
final int height;
|
||||||
final List<ImagePacket?> fragments; // indexed by fragment.index
|
final List<ImagePacket?> fragments; // indexed by fragment.index
|
||||||
|
DateTime? firstFragmentAt;
|
||||||
|
DateTime? lastFragmentAt;
|
||||||
|
|
||||||
ImageSession({
|
ImageSession({
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
@@ -25,6 +29,21 @@ class ImageSession {
|
|||||||
int get receivedCount => fragments.where((f) => f != null).length;
|
int get receivedCount => fragments.where((f) => f != null).length;
|
||||||
bool get isComplete => receivedCount == total;
|
bool get isComplete => receivedCount == total;
|
||||||
|
|
||||||
|
Duration? estimateRemaining() {
|
||||||
|
if (isComplete) return Duration.zero;
|
||||||
|
if (firstFragmentAt == null || lastFragmentAt == null) return null;
|
||||||
|
if (receivedCount < 2) return null;
|
||||||
|
|
||||||
|
final elapsedMs = lastFragmentAt!
|
||||||
|
.difference(firstFragmentAt!)
|
||||||
|
.inMilliseconds;
|
||||||
|
if (elapsedMs <= 0) return null;
|
||||||
|
final avgMsPerFragment = elapsedMs / (receivedCount - 1);
|
||||||
|
final remaining = total - receivedCount;
|
||||||
|
if (remaining <= 0) return Duration.zero;
|
||||||
|
return Duration(milliseconds: (avgMsPerFragment * remaining).round());
|
||||||
|
}
|
||||||
|
|
||||||
/// Reassemble the complete image bytes, or null if any fragment is missing.
|
/// Reassemble the complete image bytes, or null if any fragment is missing.
|
||||||
Uint8List? get imageBytes => reassembleImage(fragments);
|
Uint8List? get imageBytes => reassembleImage(fragments);
|
||||||
}
|
}
|
||||||
@@ -40,6 +59,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 = {};
|
||||||
@@ -56,12 +76,18 @@ class ImageProvider with ChangeNotifier {
|
|||||||
_restore();
|
_restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey);
|
||||||
|
|
||||||
// ── Accessors ────────────────────────────────────────────────────────────
|
// ── Accessors ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ImageSession? session(String sessionId) => _sessions[sessionId];
|
ImageSession? session(String sessionId) => _sessions[sessionId];
|
||||||
bool isComplete(String sessionId) =>
|
bool isComplete(String sessionId) =>
|
||||||
_sessions[sessionId]?.isComplete ?? false;
|
_sessions[sessionId]?.isComplete ?? false;
|
||||||
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
|
bool hasOutgoing(String sessionId) => _outgoing.containsKey(sessionId);
|
||||||
|
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||||
|
_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];
|
||||||
@@ -73,28 +99,61 @@ 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) {
|
||||||
|
final wasMissing = session.fragments[fragment.index] == null;
|
||||||
session.fragments[fragment.index] = fragment;
|
session.fragments[fragment.index] = fragment;
|
||||||
|
if (wasMissing) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
session.firstFragmentAt ??= now;
|
||||||
|
session.lastFragmentAt = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final justComplete = session.isComplete;
|
final justComplete = session.isComplete;
|
||||||
@@ -103,22 +162,27 @@ 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) {
|
||||||
_sessions.putIfAbsent(
|
if (_ignoredIncomingSessions.contains(envelope.sessionId)) {
|
||||||
envelope.sessionId,
|
return;
|
||||||
() => ImageSession(
|
}
|
||||||
sessionId: envelope.sessionId,
|
final existing = _sessions[envelope.sessionId];
|
||||||
format: envelope.format,
|
if (existing == null) {
|
||||||
total: envelope.total,
|
|
||||||
width: envelope.width,
|
|
||||||
height: envelope.height,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// Update dimensions if we created the session from a fragment (w/h = 0).
|
|
||||||
final session = _sessions[envelope.sessionId]!;
|
|
||||||
if (session.width == 0 || session.height == 0) {
|
|
||||||
_sessions[envelope.sessionId] = ImageSession(
|
_sessions[envelope.sessionId] = ImageSession(
|
||||||
sessionId: envelope.sessionId,
|
sessionId: envelope.sessionId,
|
||||||
format: envelope.format,
|
format: envelope.format,
|
||||||
@@ -126,12 +190,38 @@ class ImageProvider with ChangeNotifier {
|
|||||||
width: envelope.width,
|
width: envelope.width,
|
||||||
height: envelope.height,
|
height: envelope.height,
|
||||||
);
|
);
|
||||||
// Copy existing fragments into the new session.
|
unawaited(_persist());
|
||||||
final old = _sessions[envelope.sessionId]!;
|
notifyListeners();
|
||||||
for (var i = 0; i < session.fragments.length && i < old.total; i++) {
|
return;
|
||||||
old.fragments[i] = session.fragments[i];
|
}
|
||||||
|
|
||||||
|
final needsMerge =
|
||||||
|
existing.width == 0 ||
|
||||||
|
existing.height == 0 ||
|
||||||
|
existing.total != envelope.total ||
|
||||||
|
existing.format != envelope.format;
|
||||||
|
if (!needsMerge) {
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final merged = ImageSession(
|
||||||
|
sessionId: envelope.sessionId,
|
||||||
|
format: envelope.format,
|
||||||
|
total: envelope.total,
|
||||||
|
width: envelope.width,
|
||||||
|
height: envelope.height,
|
||||||
|
);
|
||||||
|
merged.firstFragmentAt = existing.firstFragmentAt;
|
||||||
|
merged.lastFragmentAt = existing.lastFragmentAt;
|
||||||
|
for (final fragment in existing.fragments) {
|
||||||
|
if (fragment == null) continue;
|
||||||
|
if (fragment.index < merged.total) {
|
||||||
|
merged.fragments[fragment.index] = fragment;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_sessions[envelope.sessionId] = merged;
|
||||||
|
unawaited(_persist());
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,62 +268,49 @@ 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)
|
||||||
return false;
|
: _sessions[sessionId]?.fragments.whereType<ImagePacket>().toList() ??
|
||||||
}
|
const <ImagePacket>[];
|
||||||
if (sendRawPacketCallback == null) {
|
if (fragments.isEmpty) {
|
||||||
debugPrint('⚠️ [ImageProvider] sendRawPacketCallback not set');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (requester.outPathLen < 0) {
|
|
||||||
debugPrint('⚠️ [ImageProvider] ${requester.advName} has no direct path');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (requester.outPathLen > maxDirectPayloadHops) {
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [ImageProvider] ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
|
'⚠️ [ImageProvider] No cached or received session for $sessionId',
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return serveCachedSessionFragments<ImagePacket>(
|
||||||
for (final fragment in cached.fragments) {
|
providerLabel: 'ImageProvider',
|
||||||
if (requestedIndices != null &&
|
sessionId: sessionId,
|
||||||
!requestedIndices.contains(fragment.index)) {
|
requester: requester,
|
||||||
continue;
|
fragments: fragments,
|
||||||
}
|
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||||
try {
|
indexOf: (fragment) => fragment.index,
|
||||||
await sendRawPacketCallback!(
|
encodeBinary: (fragment) => fragment.encodeBinary(),
|
||||||
contactPath: requester.outPath,
|
sendRawPacket: sendRawPacketCallback,
|
||||||
contactPathLen: requester.outPathLen,
|
requestedIndices: requestedIndices,
|
||||||
payload: fragment.encodeBinary(),
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint('❌ [ImageProvider] Serve error for $sessionId: $e\n$st');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
debugPrint(
|
|
||||||
'📷 [ImageProvider] Served ${cached.fragments.length} fragments of $sessionId',
|
|
||||||
);
|
);
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Persistence ──────────────────────────────────────────────────────────
|
// ── Persistence ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> clearAll() async {
|
Future<void> clearAll() async {
|
||||||
_sessions.clear();
|
_resetInMemoryState();
|
||||||
_outgoing.clear();
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_storageKey);
|
await prefs.remove(_scopedStorageKey());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
|
debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> reloadProfileScopedState() async {
|
||||||
|
_resetInMemoryState();
|
||||||
|
await _restore();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void _evictExpiredOutgoing() {
|
void _evictExpiredOutgoing() {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
|
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
|
||||||
@@ -273,7 +350,7 @@ class ImageProvider with ChangeNotifier {
|
|||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
};
|
};
|
||||||
await prefs.setString(_storageKey, jsonEncode(payload));
|
await prefs.setString(_scopedStorageKey(), jsonEncode(payload));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ImageProvider] Failed to persist: $e');
|
debugPrint('❌ [ImageProvider] Failed to persist: $e');
|
||||||
}
|
}
|
||||||
@@ -282,7 +359,7 @@ class ImageProvider with ChangeNotifier {
|
|||||||
Future<void> _restore() async {
|
Future<void> _restore() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final raw = prefs.getString(_storageKey);
|
final raw = prefs.getString(_scopedStorageKey());
|
||||||
if (raw == null || raw.isEmpty) return;
|
if (raw == null || raw.isEmpty) return;
|
||||||
|
|
||||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
@@ -358,6 +435,12 @@ class ImageProvider with ChangeNotifier {
|
|||||||
debugPrint('❌ [ImageProvider] Failed to restore: $e');
|
debugPrint('❌ [ImageProvider] Failed to restore: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _resetInMemoryState() {
|
||||||
|
_sessions.clear();
|
||||||
|
_outgoing.clear();
|
||||||
|
_ignoredIncomingSessions.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _OutgoingSession {
|
class _OutgoingSession {
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/custom_map_config.dart';
|
||||||
import '../models/location_trail.dart';
|
import '../models/location_trail.dart';
|
||||||
|
import '../models/map_coordinate_space.dart';
|
||||||
import '../models/map_drawing.dart';
|
import '../models/map_drawing.dart';
|
||||||
|
import '../models/sar_marker.dart';
|
||||||
|
import '../services/profiles_feature_service.dart';
|
||||||
|
import '../utils/custom_map_id.dart';
|
||||||
|
|
||||||
class MapProvider with ChangeNotifier {
|
class MapProvider with ChangeNotifier {
|
||||||
|
static const String _customMapConfigKey = 'custom_map_config_v1';
|
||||||
|
static const String _customMapModeKey = 'custom_map_mode_v1';
|
||||||
|
static const String _customMapsDirName = 'custom_maps';
|
||||||
|
|
||||||
|
MapProvider() {
|
||||||
|
unawaited(_loadInitialState());
|
||||||
|
}
|
||||||
|
|
||||||
|
final ImagePicker _imagePicker = ImagePicker();
|
||||||
|
|
||||||
LatLng? _targetLocation;
|
LatLng? _targetLocation;
|
||||||
|
LatLngBounds? _targetBounds;
|
||||||
double? _targetZoom;
|
double? _targetZoom;
|
||||||
bool _shouldAnimate = false;
|
bool _shouldAnimate = false;
|
||||||
|
MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||||
|
String? _targetMapId;
|
||||||
|
|
||||||
// Track which contact paths are currently visible
|
|
||||||
final Set<String> _visibleContactPaths = {};
|
|
||||||
|
|
||||||
// Location trail tracking
|
|
||||||
LocationTrail? _currentTrail;
|
LocationTrail? _currentTrail;
|
||||||
bool _isTrailVisible = true;
|
bool _isTrailVisible = true;
|
||||||
final List<LocationTrail> _trailHistory = [];
|
final List<LocationTrail> _trailHistory = [];
|
||||||
|
|
||||||
// WMS overlay toggles
|
|
||||||
bool _showCadastralOverlay = false;
|
bool _showCadastralOverlay = false;
|
||||||
bool _showForestRoadsOverlay = false;
|
bool _showForestRoadsOverlay = false;
|
||||||
bool _showHikingTrailsOverlay = false;
|
bool _showHikingTrailsOverlay = false;
|
||||||
@@ -31,28 +53,27 @@ class MapProvider with ChangeNotifier {
|
|||||||
bool _showPlaceNamesOverlay = false;
|
bool _showPlaceNamesOverlay = false;
|
||||||
bool _showMunicipalityBordersOverlay = false;
|
bool _showMunicipalityBordersOverlay = false;
|
||||||
|
|
||||||
// Contact trail toggles
|
bool _hideRepeatersOnMap = false;
|
||||||
bool _showAllContactTrails = true; // Default to showing all contact trails
|
|
||||||
|
|
||||||
// Imported trail (from GPX)
|
|
||||||
LocationTrail? _importedTrail;
|
LocationTrail? _importedTrail;
|
||||||
|
|
||||||
// Download area selection
|
|
||||||
bool _isSelectingDownloadArea = false;
|
bool _isSelectingDownloadArea = false;
|
||||||
LatLngBounds? _downloadAreaBounds;
|
LatLngBounds? _downloadAreaBounds;
|
||||||
|
|
||||||
|
CustomMapConfig? _customMapConfig;
|
||||||
|
bool _isUsingCustomMap = false;
|
||||||
|
|
||||||
LatLng? get targetLocation => _targetLocation;
|
LatLng? get targetLocation => _targetLocation;
|
||||||
|
LatLngBounds? get targetBounds => _targetBounds;
|
||||||
double? get targetZoom => _targetZoom;
|
double? get targetZoom => _targetZoom;
|
||||||
bool get shouldAnimate => _shouldAnimate;
|
bool get shouldAnimate => _shouldAnimate;
|
||||||
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
|
MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace;
|
||||||
|
String? get targetMapId => _targetMapId;
|
||||||
// Trail getters
|
|
||||||
LocationTrail? get currentTrail => _currentTrail;
|
LocationTrail? get currentTrail => _currentTrail;
|
||||||
bool get isTrailVisible => _isTrailVisible;
|
bool get isTrailVisible => _isTrailVisible;
|
||||||
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
|
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
|
||||||
bool get isTrailActive => _currentTrail?.isActive ?? false;
|
bool get isTrailActive => _currentTrail?.isActive ?? false;
|
||||||
|
|
||||||
// WMS overlay getters
|
|
||||||
bool get showCadastralOverlay => _showCadastralOverlay;
|
bool get showCadastralOverlay => _showCadastralOverlay;
|
||||||
bool get showForestRoadsOverlay => _showForestRoadsOverlay;
|
bool get showForestRoadsOverlay => _showForestRoadsOverlay;
|
||||||
bool get showHikingTrailsOverlay => _showHikingTrailsOverlay;
|
bool get showHikingTrailsOverlay => _showHikingTrailsOverlay;
|
||||||
@@ -65,68 +86,138 @@ class MapProvider with ChangeNotifier {
|
|||||||
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
|
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
|
||||||
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
|
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
|
||||||
|
|
||||||
// Contact trail getters
|
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
|
||||||
bool get showAllContactTrails => _showAllContactTrails;
|
|
||||||
|
|
||||||
// Imported trail getters
|
|
||||||
LocationTrail? get importedTrail => _importedTrail;
|
LocationTrail? get importedTrail => _importedTrail;
|
||||||
|
|
||||||
// Download area getters
|
|
||||||
bool get isSelectingDownloadArea => _isSelectingDownloadArea;
|
bool get isSelectingDownloadArea => _isSelectingDownloadArea;
|
||||||
LatLngBounds? get downloadAreaBounds => _downloadAreaBounds;
|
LatLngBounds? get downloadAreaBounds => _downloadAreaBounds;
|
||||||
|
|
||||||
|
CustomMapConfig? get customMapConfig => _customMapConfig;
|
||||||
|
bool get hasCustomMap => _customMapConfig != null;
|
||||||
|
bool get isUsingCustomMap => _isUsingCustomMap && _customMapConfig != null;
|
||||||
|
bool get shouldHideGpsData => isUsingCustomMap;
|
||||||
|
|
||||||
|
LatLngBounds? get customMapBounds => _customMapConfig?.bounds;
|
||||||
|
|
||||||
|
Future<void> reloadProfileScopedState() async {
|
||||||
|
await _loadInitialState();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool matchesActiveCustomMap(String? mapId) {
|
||||||
|
return hasCustomMap &&
|
||||||
|
normalizeCustomMapId(_customMapConfig!.mapId) ==
|
||||||
|
normalizeCustomMapId(mapId);
|
||||||
|
}
|
||||||
|
|
||||||
void navigateToLocation({
|
void navigateToLocation({
|
||||||
required LatLng location,
|
required LatLng location,
|
||||||
double zoom = 15.0,
|
double zoom = 15.0,
|
||||||
bool animate = true,
|
bool animate = true,
|
||||||
}) {
|
}) {
|
||||||
|
if (_isUsingCustomMap) {
|
||||||
|
_isUsingCustomMap = false;
|
||||||
|
unawaited(_saveCustomMapState());
|
||||||
|
}
|
||||||
_targetLocation = location;
|
_targetLocation = location;
|
||||||
|
_targetBounds = null;
|
||||||
_targetZoom = zoom;
|
_targetZoom = zoom;
|
||||||
_shouldAnimate = animate;
|
_shouldAnimate = animate;
|
||||||
|
_targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||||
|
_targetMapId = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? navigateToMapPoint({
|
||||||
|
required LatLng point,
|
||||||
|
required MapCoordinateSpace coordinateSpace,
|
||||||
|
String? mapId,
|
||||||
|
double zoom = 15.0,
|
||||||
|
bool animate = true,
|
||||||
|
}) {
|
||||||
|
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||||
|
if (!matchesActiveCustomMap(mapId)) {
|
||||||
|
return 'Load the matching custom map to view this item.';
|
||||||
|
}
|
||||||
|
if (!_isUsingCustomMap) {
|
||||||
|
_isUsingCustomMap = true;
|
||||||
|
unawaited(_saveCustomMapState());
|
||||||
|
}
|
||||||
|
} else if (_isUsingCustomMap) {
|
||||||
|
_isUsingCustomMap = false;
|
||||||
|
unawaited(_saveCustomMapState());
|
||||||
|
}
|
||||||
|
|
||||||
|
_targetLocation = point;
|
||||||
|
_targetBounds = null;
|
||||||
|
_targetZoom = zoom;
|
||||||
|
_shouldAnimate = animate;
|
||||||
|
_targetCoordinateSpace = coordinateSpace;
|
||||||
|
_targetMapId = mapId;
|
||||||
|
notifyListeners();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? navigateToBounds({
|
||||||
|
required LatLngBounds bounds,
|
||||||
|
required MapCoordinateSpace coordinateSpace,
|
||||||
|
String? mapId,
|
||||||
|
bool animate = true,
|
||||||
|
}) {
|
||||||
|
if (coordinateSpace == MapCoordinateSpace.customMap) {
|
||||||
|
if (!matchesActiveCustomMap(mapId)) {
|
||||||
|
return 'Load the matching custom map to view this item.';
|
||||||
|
}
|
||||||
|
if (!_isUsingCustomMap) {
|
||||||
|
_isUsingCustomMap = true;
|
||||||
|
unawaited(_saveCustomMapState());
|
||||||
|
}
|
||||||
|
} else if (_isUsingCustomMap) {
|
||||||
|
_isUsingCustomMap = false;
|
||||||
|
unawaited(_saveCustomMapState());
|
||||||
|
}
|
||||||
|
|
||||||
|
_targetBounds = bounds;
|
||||||
|
_targetLocation = bounds.center;
|
||||||
|
_targetZoom = null;
|
||||||
|
_shouldAnimate = animate;
|
||||||
|
_targetCoordinateSpace = coordinateSpace;
|
||||||
|
_targetMapId = mapId;
|
||||||
|
notifyListeners();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void clearNavigation() {
|
void clearNavigation() {
|
||||||
_targetLocation = null;
|
_targetLocation = null;
|
||||||
|
_targetBounds = null;
|
||||||
_targetZoom = null;
|
_targetZoom = null;
|
||||||
_shouldAnimate = false;
|
_shouldAnimate = false;
|
||||||
// Don't notify listeners to avoid rebuilds
|
_targetCoordinateSpace = MapCoordinateSpace.geo;
|
||||||
|
_targetMapId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Navigate to a drawing by its ID
|
String? navigateToDrawing(String drawingId, dynamic drawingProvider) {
|
||||||
void navigateToDrawing(String drawingId, dynamic drawingProvider) {
|
final drawing = drawingProvider.getDrawingById(drawingId) as MapDrawing?;
|
||||||
debugPrint('🗺️ [MapProvider] navigateToDrawing called with ID: $drawingId');
|
|
||||||
// Find the drawing in the provider
|
|
||||||
final drawings = drawingProvider.drawings as List;
|
|
||||||
debugPrint('🗺️ [MapProvider] Total drawings in provider: ${drawings.length}');
|
|
||||||
final drawing = drawings.cast<dynamic>().firstWhere(
|
|
||||||
(d) => d.id == drawingId,
|
|
||||||
orElse: () => null,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (drawing == null) {
|
if (drawing == null) {
|
||||||
debugPrint('⚠️ [MapProvider] Drawing $drawingId not found');
|
return 'Drawing not found.';
|
||||||
debugPrint('⚠️ [MapProvider] Available drawing IDs: ${drawings.map((d) => d.id).toList()}');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use MapDrawing's built-in getCenter and getBounds methods
|
if (drawing.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||||
final center = drawing.getCenter();
|
if (!matchesActiveCustomMap(drawing.mapId)) {
|
||||||
final bounds = drawing.getBounds();
|
return 'Load the matching custom map to view this drawing.';
|
||||||
|
}
|
||||||
|
return navigateToBounds(
|
||||||
|
bounds: drawing.getBounds(),
|
||||||
|
coordinateSpace: drawing.coordinateSpace,
|
||||||
|
mapId: drawing.mapId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate appropriate zoom level based on bounds
|
final bounds = drawing.getBounds();
|
||||||
// For larger drawings, use lower zoom to fit the whole drawing
|
|
||||||
// For smaller drawings, use higher zoom for better detail
|
|
||||||
final latDiff = (bounds.north - bounds.south).abs();
|
final latDiff = (bounds.north - bounds.south).abs();
|
||||||
final lonDiff = (bounds.east - bounds.west).abs();
|
final lonDiff = (bounds.east - bounds.west).abs();
|
||||||
final maxDiff = latDiff > lonDiff ? latDiff : lonDiff;
|
final maxDiff = latDiff > lonDiff ? latDiff : lonDiff;
|
||||||
|
|
||||||
// Zoom scale: smaller drawings get higher zoom
|
|
||||||
// 0.001 degrees (~100m) -> zoom 17
|
|
||||||
// 0.005 degrees (~500m) -> zoom 16
|
|
||||||
// 0.01 degrees (~1km) -> zoom 15
|
|
||||||
// 0.05 degrees (~5km) -> zoom 13
|
|
||||||
// 0.1 degrees (~10km) -> zoom 12
|
|
||||||
double zoom = 15.0;
|
double zoom = 15.0;
|
||||||
if (maxDiff < 0.001) {
|
if (maxDiff < 0.001) {
|
||||||
zoom = 17.0;
|
zoom = 17.0;
|
||||||
@@ -142,9 +233,125 @@ class MapProvider with ChangeNotifier {
|
|||||||
zoom = 10.0;
|
zoom = 10.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
final typeStr = drawing is LineDrawing ? 'line' : 'rectangle';
|
navigateToLocation(
|
||||||
debugPrint('🗺️ [MapProvider] Navigating to drawing: $typeStr, zoom: $zoom');
|
location: drawing.getCenter(),
|
||||||
navigateToLocation(location: center, zoom: zoom, animate: true);
|
zoom: zoom,
|
||||||
|
animate: true,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? navigateToSarMarker(SarMarker marker) {
|
||||||
|
if (marker.coordinateSpace == MapCoordinateSpace.customMap) {
|
||||||
|
return navigateToMapPoint(
|
||||||
|
point: marker.location,
|
||||||
|
coordinateSpace: MapCoordinateSpace.customMap,
|
||||||
|
mapId: marker.mapId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
navigateToLocation(location: marker.location, zoom: 15.0, animate: true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> loadCustomMapFromGallery() async {
|
||||||
|
final picked = await _imagePicker.pickImage(source: ImageSource.gallery);
|
||||||
|
if (picked == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final bytes = await picked.readAsBytes();
|
||||||
|
await setCustomMapImage(bytes: bytes, displayName: picked.name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> replaceCustomMap() async {
|
||||||
|
await loadCustomMapFromGallery();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setCustomMapImage({
|
||||||
|
required Uint8List bytes,
|
||||||
|
required String displayName,
|
||||||
|
}) async {
|
||||||
|
final dimensions = await _decodeImageSize(bytes);
|
||||||
|
final mapId = normalizeCustomMapId(sha256.convert(bytes).toString())!;
|
||||||
|
final documentsDir = await getApplicationDocumentsDirectory();
|
||||||
|
final dir = Directory('${documentsDir.path}/$_customMapsDirName');
|
||||||
|
if (!await dir.exists()) {
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
final extension = _normalizedExtension(displayName);
|
||||||
|
final nextPath = '${dir.path}/custom_map_$mapId.$extension';
|
||||||
|
final nextFile = File(nextPath);
|
||||||
|
await nextFile.writeAsBytes(bytes, flush: true);
|
||||||
|
|
||||||
|
final previousPath = _customMapConfig?.filePath;
|
||||||
|
_customMapConfig = CustomMapConfig(
|
||||||
|
filePath: nextPath,
|
||||||
|
displayName: displayName,
|
||||||
|
mapId: mapId,
|
||||||
|
imageWidth: dimensions.$1,
|
||||||
|
imageHeight: dimensions.$2,
|
||||||
|
);
|
||||||
|
_isUsingCustomMap = true;
|
||||||
|
await _saveCustomMapState();
|
||||||
|
|
||||||
|
if (previousPath != null && previousPath != nextPath) {
|
||||||
|
unawaited(_deleteFileIfExists(previousPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setCustomMapCalibration({
|
||||||
|
required LatLng pointA,
|
||||||
|
required LatLng pointB,
|
||||||
|
required double metersPerPixel,
|
||||||
|
}) async {
|
||||||
|
if (_customMapConfig == null) return;
|
||||||
|
_customMapConfig = _customMapConfig!.copyWith(
|
||||||
|
calibrationPointA: pointA,
|
||||||
|
calibrationPointB: pointB,
|
||||||
|
metersPerPixel: metersPerPixel,
|
||||||
|
);
|
||||||
|
await _saveCustomMapState();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearCustomMapCalibration() async {
|
||||||
|
if (_customMapConfig == null) return;
|
||||||
|
_customMapConfig = _customMapConfig!.copyWith(
|
||||||
|
clearMetersPerPixel: true,
|
||||||
|
clearCalibrationPointA: true,
|
||||||
|
clearCalibrationPointB: true,
|
||||||
|
);
|
||||||
|
await _saveCustomMapState();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeCustomMap() async {
|
||||||
|
final filePath = _customMapConfig?.filePath;
|
||||||
|
_customMapConfig = null;
|
||||||
|
_isUsingCustomMap = false;
|
||||||
|
clearNavigation();
|
||||||
|
await _saveCustomMapState();
|
||||||
|
if (filePath != null) {
|
||||||
|
await _deleteFileIfExists(filePath);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> enterCustomMapMode() async {
|
||||||
|
if (!hasCustomMap || _isUsingCustomMap) return;
|
||||||
|
_isUsingCustomMap = true;
|
||||||
|
await _saveCustomMapState();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> exitCustomMapMode() async {
|
||||||
|
if (!_isUsingCustomMap) return;
|
||||||
|
_isUsingCustomMap = false;
|
||||||
|
await _saveCustomMapState();
|
||||||
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateZoom(double zoom) {
|
void updateZoom(double zoom) {
|
||||||
@@ -152,37 +359,7 @@ class MapProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle path visibility for a contact
|
|
||||||
void toggleContactPath(String publicKeyHex) {
|
|
||||||
if (_visibleContactPaths.contains(publicKeyHex)) {
|
|
||||||
_visibleContactPaths.remove(publicKeyHex);
|
|
||||||
} else {
|
|
||||||
_visibleContactPaths.add(publicKeyHex);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a contact's path is visible
|
|
||||||
bool isContactPathVisible(String publicKeyHex) {
|
|
||||||
return _visibleContactPaths.contains(publicKeyHex);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hide all contact paths
|
|
||||||
void hideAllPaths() {
|
|
||||||
_visibleContactPaths.clear();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Show path for specific contact (hide all others)
|
|
||||||
void showOnlyPath(String publicKeyHex) {
|
|
||||||
_visibleContactPaths.clear();
|
|
||||||
_visibleContactPaths.add(publicKeyHex);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start a new location trail
|
|
||||||
void startTrail() {
|
void startTrail() {
|
||||||
// End current trail if active
|
|
||||||
if (_currentTrail != null && _currentTrail!.isActive) {
|
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||||
endTrail();
|
endTrail();
|
||||||
}
|
}
|
||||||
@@ -195,22 +372,22 @@ class MapProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a point to the current trail
|
|
||||||
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
|
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
|
||||||
if (_currentTrail == null || !_currentTrail!.isActive) {
|
if (_currentTrail == null || !_currentTrail!.isActive) {
|
||||||
startTrail();
|
startTrail();
|
||||||
}
|
}
|
||||||
|
|
||||||
_currentTrail!.addPoint(TrailPoint(
|
_currentTrail!.addPoint(
|
||||||
position: position,
|
TrailPoint(
|
||||||
timestamp: DateTime.now(),
|
position: position,
|
||||||
accuracy: accuracy,
|
timestamp: DateTime.now(),
|
||||||
speed: speed,
|
accuracy: accuracy,
|
||||||
));
|
speed: speed,
|
||||||
|
),
|
||||||
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// End the current trail
|
|
||||||
void endTrail() {
|
void endTrail() {
|
||||||
if (_currentTrail != null) {
|
if (_currentTrail != null) {
|
||||||
_currentTrail!.isActive = false;
|
_currentTrail!.isActive = false;
|
||||||
@@ -223,13 +400,11 @@ class MapProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle trail visibility
|
|
||||||
void toggleTrailVisibility() {
|
void toggleTrailVisibility() {
|
||||||
_isTrailVisible = !_isTrailVisible;
|
_isTrailVisible = !_isTrailVisible;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear the current trail
|
|
||||||
void clearCurrentTrail() {
|
void clearCurrentTrail() {
|
||||||
if (_currentTrail != null) {
|
if (_currentTrail != null) {
|
||||||
_currentTrail = null;
|
_currentTrail = null;
|
||||||
@@ -237,197 +412,344 @@ class MapProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all trail history
|
|
||||||
void clearAllTrails() {
|
void clearAllTrails() {
|
||||||
_currentTrail = null;
|
_currentTrail = null;
|
||||||
_trailHistory.clear();
|
_trailHistory.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get total trail distance in meters
|
|
||||||
double get totalTrailDistance {
|
double get totalTrailDistance {
|
||||||
if (_currentTrail == null) return 0;
|
if (_currentTrail == null) return 0;
|
||||||
return _currentTrail!.totalDistance;
|
return _currentTrail!.totalDistance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get trail duration
|
|
||||||
Duration get trailDuration {
|
Duration get trailDuration {
|
||||||
if (_currentTrail == null) return Duration.zero;
|
if (_currentTrail == null) return Duration.zero;
|
||||||
return _currentTrail!.duration;
|
return _currentTrail!.duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle cadastral parcels overlay
|
|
||||||
Future<void> toggleCadastralOverlay() async {
|
Future<void> toggleCadastralOverlay() async {
|
||||||
_showCadastralOverlay = !_showCadastralOverlay;
|
_showCadastralOverlay = !_showCadastralOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle forest roads overlay
|
|
||||||
Future<void> toggleForestRoadsOverlay() async {
|
Future<void> toggleForestRoadsOverlay() async {
|
||||||
_showForestRoadsOverlay = !_showForestRoadsOverlay;
|
_showForestRoadsOverlay = !_showForestRoadsOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle hiking trails overlay
|
|
||||||
Future<void> toggleHikingTrailsOverlay() async {
|
Future<void> toggleHikingTrailsOverlay() async {
|
||||||
_showHikingTrailsOverlay = !_showHikingTrailsOverlay;
|
_showHikingTrailsOverlay = !_showHikingTrailsOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle main roads overlay
|
|
||||||
Future<void> toggleMainRoadsOverlay() async {
|
Future<void> toggleMainRoadsOverlay() async {
|
||||||
_showMainRoadsOverlay = !_showMainRoadsOverlay;
|
_showMainRoadsOverlay = !_showMainRoadsOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle house numbers overlay
|
|
||||||
Future<void> toggleHouseNumbersOverlay() async {
|
Future<void> toggleHouseNumbersOverlay() async {
|
||||||
_showHouseNumbersOverlay = !_showHouseNumbersOverlay;
|
_showHouseNumbersOverlay = !_showHouseNumbersOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle fire hazard zones overlay
|
|
||||||
Future<void> toggleFireHazardZonesOverlay() async {
|
Future<void> toggleFireHazardZonesOverlay() async {
|
||||||
_showFireHazardZonesOverlay = !_showFireHazardZonesOverlay;
|
_showFireHazardZonesOverlay = !_showFireHazardZonesOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle historical fires overlay
|
|
||||||
Future<void> toggleHistoricalFiresOverlay() async {
|
Future<void> toggleHistoricalFiresOverlay() async {
|
||||||
_showHistoricalFiresOverlay = !_showHistoricalFiresOverlay;
|
_showHistoricalFiresOverlay = !_showHistoricalFiresOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle firebreaks overlay
|
|
||||||
Future<void> toggleFirebreaksOverlay() async {
|
Future<void> toggleFirebreaksOverlay() async {
|
||||||
_showFirebreaksOverlay = !_showFirebreaksOverlay;
|
_showFirebreaksOverlay = !_showFirebreaksOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle Kras fire zones overlay
|
|
||||||
Future<void> toggleKrasFireZonesOverlay() async {
|
Future<void> toggleKrasFireZonesOverlay() async {
|
||||||
_showKrasFireZonesOverlay = !_showKrasFireZonesOverlay;
|
_showKrasFireZonesOverlay = !_showKrasFireZonesOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle place names overlay
|
|
||||||
Future<void> togglePlaceNamesOverlay() async {
|
Future<void> togglePlaceNamesOverlay() async {
|
||||||
_showPlaceNamesOverlay = !_showPlaceNamesOverlay;
|
_showPlaceNamesOverlay = !_showPlaceNamesOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle municipality borders overlay
|
|
||||||
Future<void> toggleMunicipalityBordersOverlay() async {
|
Future<void> toggleMunicipalityBordersOverlay() async {
|
||||||
_showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay;
|
_showMunicipalityBordersOverlay = !_showMunicipalityBordersOverlay;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveOverlayState();
|
await _saveOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load overlay state from SharedPreferences
|
|
||||||
Future<void> loadOverlayState() async {
|
Future<void> loadOverlayState() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_showCadastralOverlay = prefs.getBool('map_show_cadastral_overlay') ?? false;
|
_showCadastralOverlay =
|
||||||
_showForestRoadsOverlay = prefs.getBool('map_show_forest_roads_overlay') ?? false;
|
prefs.getBool(_scopedKey('map_show_cadastral_overlay')) ?? false;
|
||||||
_showHikingTrailsOverlay = prefs.getBool('map_show_hiking_trails_overlay') ?? false;
|
_showForestRoadsOverlay =
|
||||||
_showMainRoadsOverlay = prefs.getBool('map_show_main_roads_overlay') ?? false;
|
prefs.getBool(_scopedKey('map_show_forest_roads_overlay')) ?? false;
|
||||||
_showHouseNumbersOverlay = prefs.getBool('map_show_house_numbers_overlay') ?? false;
|
_showHikingTrailsOverlay =
|
||||||
_showFireHazardZonesOverlay = prefs.getBool('map_show_fire_hazard_zones_overlay') ?? false;
|
prefs.getBool(_scopedKey('map_show_hiking_trails_overlay')) ?? false;
|
||||||
_showHistoricalFiresOverlay = prefs.getBool('map_show_historical_fires_overlay') ?? false;
|
_showMainRoadsOverlay =
|
||||||
_showFirebreaksOverlay = prefs.getBool('map_show_firebreaks_overlay') ?? false;
|
prefs.getBool(_scopedKey('map_show_main_roads_overlay')) ?? false;
|
||||||
_showKrasFireZonesOverlay = prefs.getBool('map_show_kras_fire_zones_overlay') ?? false;
|
_showHouseNumbersOverlay =
|
||||||
_showPlaceNamesOverlay = prefs.getBool('map_show_place_names_overlay') ?? false;
|
prefs.getBool(_scopedKey('map_show_house_numbers_overlay')) ?? false;
|
||||||
_showMunicipalityBordersOverlay = prefs.getBool('map_show_municipality_borders_overlay') ?? false;
|
_showFireHazardZonesOverlay =
|
||||||
|
prefs.getBool(_scopedKey('map_show_fire_hazard_zones_overlay')) ??
|
||||||
|
false;
|
||||||
|
_showHistoricalFiresOverlay =
|
||||||
|
prefs.getBool(_scopedKey('map_show_historical_fires_overlay')) ?? false;
|
||||||
|
_showFirebreaksOverlay =
|
||||||
|
prefs.getBool(_scopedKey('map_show_firebreaks_overlay')) ?? false;
|
||||||
|
_showKrasFireZonesOverlay =
|
||||||
|
prefs.getBool(_scopedKey('map_show_kras_fire_zones_overlay')) ?? false;
|
||||||
|
_showPlaceNamesOverlay =
|
||||||
|
prefs.getBool(_scopedKey('map_show_place_names_overlay')) ?? false;
|
||||||
|
_showMunicipalityBordersOverlay =
|
||||||
|
prefs.getBool(_scopedKey('map_show_municipality_borders_overlay')) ??
|
||||||
|
false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save overlay state to SharedPreferences
|
Future<void> _loadInitialState() async {
|
||||||
|
await Future.wait([
|
||||||
|
loadOverlayState(),
|
||||||
|
loadRepeaterVisibilitySettings(),
|
||||||
|
_loadCustomMapState(),
|
||||||
|
_removeDeprecatedSettings(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _removeDeprecatedSettings() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_scopedKey('map_show_all_contact_trails'));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _saveOverlayState() async {
|
Future<void> _saveOverlayState() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool('map_show_cadastral_overlay', _showCadastralOverlay);
|
await prefs.setBool(
|
||||||
await prefs.setBool('map_show_forest_roads_overlay', _showForestRoadsOverlay);
|
_scopedKey('map_show_cadastral_overlay'),
|
||||||
await prefs.setBool('map_show_hiking_trails_overlay', _showHikingTrailsOverlay);
|
_showCadastralOverlay,
|
||||||
await prefs.setBool('map_show_main_roads_overlay', _showMainRoadsOverlay);
|
);
|
||||||
await prefs.setBool('map_show_house_numbers_overlay', _showHouseNumbersOverlay);
|
await prefs.setBool(
|
||||||
await prefs.setBool('map_show_fire_hazard_zones_overlay', _showFireHazardZonesOverlay);
|
_scopedKey('map_show_forest_roads_overlay'),
|
||||||
await prefs.setBool('map_show_historical_fires_overlay', _showHistoricalFiresOverlay);
|
_showForestRoadsOverlay,
|
||||||
await prefs.setBool('map_show_firebreaks_overlay', _showFirebreaksOverlay);
|
);
|
||||||
await prefs.setBool('map_show_kras_fire_zones_overlay', _showKrasFireZonesOverlay);
|
await prefs.setBool(
|
||||||
await prefs.setBool('map_show_place_names_overlay', _showPlaceNamesOverlay);
|
_scopedKey('map_show_hiking_trails_overlay'),
|
||||||
await prefs.setBool('map_show_municipality_borders_overlay', _showMunicipalityBordersOverlay);
|
_showHikingTrailsOverlay,
|
||||||
|
);
|
||||||
|
await prefs.setBool(
|
||||||
|
_scopedKey('map_show_main_roads_overlay'),
|
||||||
|
_showMainRoadsOverlay,
|
||||||
|
);
|
||||||
|
await prefs.setBool(
|
||||||
|
_scopedKey('map_show_house_numbers_overlay'),
|
||||||
|
_showHouseNumbersOverlay,
|
||||||
|
);
|
||||||
|
await prefs.setBool(
|
||||||
|
_scopedKey('map_show_fire_hazard_zones_overlay'),
|
||||||
|
_showFireHazardZonesOverlay,
|
||||||
|
);
|
||||||
|
await prefs.setBool(
|
||||||
|
_scopedKey('map_show_historical_fires_overlay'),
|
||||||
|
_showHistoricalFiresOverlay,
|
||||||
|
);
|
||||||
|
await prefs.setBool(
|
||||||
|
_scopedKey('map_show_firebreaks_overlay'),
|
||||||
|
_showFirebreaksOverlay,
|
||||||
|
);
|
||||||
|
await prefs.setBool(
|
||||||
|
_scopedKey('map_show_kras_fire_zones_overlay'),
|
||||||
|
_showKrasFireZonesOverlay,
|
||||||
|
);
|
||||||
|
await prefs.setBool(
|
||||||
|
_scopedKey('map_show_place_names_overlay'),
|
||||||
|
_showPlaceNamesOverlay,
|
||||||
|
);
|
||||||
|
await prefs.setBool(
|
||||||
|
_scopedKey('map_show_municipality_borders_overlay'),
|
||||||
|
_showMunicipalityBordersOverlay,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle all contact trails on/off
|
Future<void> setHideRepeatersOnMap(bool hide) async {
|
||||||
Future<void> toggleAllContactTrails() async {
|
if (_hideRepeatersOnMap == hide) return;
|
||||||
_showAllContactTrails = !_showAllContactTrails;
|
_hideRepeatersOnMap = hide;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _saveTrailSettings();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(_scopedKey('map_hide_repeaters'), _hideRepeatersOnMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load trail settings from SharedPreferences
|
Future<void> loadRepeaterVisibilitySettings() async {
|
||||||
Future<void> loadTrailSettings() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_showAllContactTrails = prefs.getBool('map_show_all_contact_trails') ?? true; // Default to true (show all)
|
_hideRepeatersOnMap =
|
||||||
|
prefs.getBool(_scopedKey('map_hide_repeaters')) ?? false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save trail settings to SharedPreferences
|
|
||||||
Future<void> _saveTrailSettings() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setBool('map_show_all_contact_trails', _showAllContactTrails);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set imported trail (from GPX import)
|
|
||||||
void setImportedTrail(LocationTrail trail) {
|
void setImportedTrail(LocationTrail trail) {
|
||||||
_importedTrail = trail;
|
_importedTrail = trail;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear imported trail
|
|
||||||
void clearImportedTrail() {
|
void clearImportedTrail() {
|
||||||
_importedTrail = null;
|
_importedTrail = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace current trail with imported trail
|
|
||||||
void replaceCurrentTrailWithImport(LocationTrail importedTrail) {
|
void replaceCurrentTrailWithImport(LocationTrail importedTrail) {
|
||||||
// End current trail if active
|
|
||||||
if (_currentTrail != null && _currentTrail!.isActive) {
|
if (_currentTrail != null && _currentTrail!.isActive) {
|
||||||
endTrail();
|
endTrail();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set imported trail as current trail
|
|
||||||
_currentTrail = importedTrail;
|
_currentTrail = importedTrail;
|
||||||
_isTrailVisible = true;
|
_isTrailVisible = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enter download area selection mode with initial bounds
|
|
||||||
void enterDownloadAreaMode(LatLngBounds initialBounds) {
|
void enterDownloadAreaMode(LatLngBounds initialBounds) {
|
||||||
_isSelectingDownloadArea = true;
|
_isSelectingDownloadArea = true;
|
||||||
_downloadAreaBounds = initialBounds;
|
_downloadAreaBounds = initialBounds;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exit download area selection mode
|
|
||||||
void exitDownloadAreaMode() {
|
void exitDownloadAreaMode() {
|
||||||
_isSelectingDownloadArea = false;
|
_isSelectingDownloadArea = false;
|
||||||
_downloadAreaBounds = null;
|
_downloadAreaBounds = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the download area bounds (while dragging/resizing)
|
|
||||||
void updateDownloadAreaBounds(LatLngBounds bounds) {
|
void updateDownloadAreaBounds(LatLngBounds bounds) {
|
||||||
_downloadAreaBounds = bounds;
|
_downloadAreaBounds = bounds;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadCustomMapState() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final configJson = prefs.getString(_scopedKey(_customMapConfigKey));
|
||||||
|
if (configJson != null && configJson.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(configJson);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final config = CustomMapConfig.fromJson(decoded);
|
||||||
|
if (config != null && await File(config.filePath).exists()) {
|
||||||
|
_customMapConfig = config;
|
||||||
|
} else {
|
||||||
|
_customMapConfig = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_customMapConfig = null;
|
||||||
|
}
|
||||||
|
_isUsingCustomMap =
|
||||||
|
(prefs.getBool(_scopedKey(_customMapModeKey)) ?? false) &&
|
||||||
|
_customMapConfig != null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveCustomMapState() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (_customMapConfig == null) {
|
||||||
|
await prefs.remove(_scopedKey(_customMapConfigKey));
|
||||||
|
} else {
|
||||||
|
await prefs.setString(
|
||||||
|
_scopedKey(_customMapConfigKey),
|
||||||
|
jsonEncode(_customMapConfig!.toJson()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await prefs.setBool(_scopedKey(_customMapModeKey), _isUsingCustomMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<(int, int)> _decodeImageSize(Uint8List bytes) async {
|
||||||
|
final codec = await ui.instantiateImageCodec(bytes);
|
||||||
|
final frame = await codec.getNextFrame();
|
||||||
|
return (frame.image.width, frame.image.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizedExtension(String displayName) {
|
||||||
|
final dotIndex = displayName.lastIndexOf('.');
|
||||||
|
if (dotIndex == -1 || dotIndex == displayName.length - 1) {
|
||||||
|
return 'png';
|
||||||
|
}
|
||||||
|
return displayName.substring(dotIndex + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteFileIfExists(String path) async {
|
||||||
|
final file = File(path);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> exportWorkspaceJson() {
|
||||||
|
return {
|
||||||
|
'currentTrail': _currentTrail?.toJson(),
|
||||||
|
'trailHistory': _trailHistory.map((trail) => trail.toJson()).toList(),
|
||||||
|
'importedTrail': _importedTrail?.toJson(),
|
||||||
|
'isTrailVisible': _isTrailVisible,
|
||||||
|
'showCadastralOverlay': _showCadastralOverlay,
|
||||||
|
'showForestRoadsOverlay': _showForestRoadsOverlay,
|
||||||
|
'showHikingTrailsOverlay': _showHikingTrailsOverlay,
|
||||||
|
'showMainRoadsOverlay': _showMainRoadsOverlay,
|
||||||
|
'showHouseNumbersOverlay': _showHouseNumbersOverlay,
|
||||||
|
'showFireHazardZonesOverlay': _showFireHazardZonesOverlay,
|
||||||
|
'showHistoricalFiresOverlay': _showHistoricalFiresOverlay,
|
||||||
|
'showFirebreaksOverlay': _showFirebreaksOverlay,
|
||||||
|
'showKrasFireZonesOverlay': _showKrasFireZonesOverlay,
|
||||||
|
'showPlaceNamesOverlay': _showPlaceNamesOverlay,
|
||||||
|
'showMunicipalityBordersOverlay': _showMunicipalityBordersOverlay,
|
||||||
|
'hideRepeatersOnMap': _hideRepeatersOnMap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void applyWorkspaceJson(Map<String, dynamic> json) {
|
||||||
|
_currentTrail = json['currentTrail'] is Map<String, dynamic>
|
||||||
|
? LocationTrail.fromJson(json['currentTrail'] as Map<String, dynamic>)
|
||||||
|
: null;
|
||||||
|
_trailHistory
|
||||||
|
..clear()
|
||||||
|
..addAll(
|
||||||
|
(json['trailHistory'] as List<dynamic>? ?? const [])
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(LocationTrail.fromJson),
|
||||||
|
);
|
||||||
|
_importedTrail = json['importedTrail'] is Map<String, dynamic>
|
||||||
|
? LocationTrail.fromJson(json['importedTrail'] as Map<String, dynamic>)
|
||||||
|
: null;
|
||||||
|
_isTrailVisible = json['isTrailVisible'] as bool? ?? true;
|
||||||
|
_showCadastralOverlay = json['showCadastralOverlay'] as bool? ?? false;
|
||||||
|
_showForestRoadsOverlay = json['showForestRoadsOverlay'] as bool? ?? false;
|
||||||
|
_showHikingTrailsOverlay =
|
||||||
|
json['showHikingTrailsOverlay'] as bool? ?? false;
|
||||||
|
_showMainRoadsOverlay = json['showMainRoadsOverlay'] as bool? ?? false;
|
||||||
|
_showHouseNumbersOverlay =
|
||||||
|
json['showHouseNumbersOverlay'] as bool? ?? false;
|
||||||
|
_showFireHazardZonesOverlay =
|
||||||
|
json['showFireHazardZonesOverlay'] as bool? ?? false;
|
||||||
|
_showHistoricalFiresOverlay =
|
||||||
|
json['showHistoricalFiresOverlay'] as bool? ?? false;
|
||||||
|
_showFirebreaksOverlay = json['showFirebreaksOverlay'] as bool? ?? false;
|
||||||
|
_showKrasFireZonesOverlay =
|
||||||
|
json['showKrasFireZonesOverlay'] as bool? ?? false;
|
||||||
|
_showPlaceNamesOverlay = json['showPlaceNamesOverlay'] as bool? ?? false;
|
||||||
|
_showMunicipalityBordersOverlay =
|
||||||
|
json['showMunicipalityBordersOverlay'] as bool? ?? false;
|
||||||
|
_hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _scopedKey(String baseKey) {
|
||||||
|
return ProfileStorageScope.scopedKey(baseKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
541
lib/providers/offline_tiles_provider.dart
Normal file
541
lib/providers/offline_tiles_provider.dart
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../models/map_layer.dart';
|
||||||
|
import '../services/offline_tile_cache_service.dart';
|
||||||
|
import '../services/tile_download_service.dart';
|
||||||
|
import '../services/tile_math_service.dart';
|
||||||
|
import '../services/tile_sharing_service.dart';
|
||||||
|
|
||||||
|
export '../services/tile_sharing_service.dart' show TilePeer, PeerCatalog;
|
||||||
|
export '../services/offline_tile_cache_service.dart'
|
||||||
|
show StyleInfo, DownloadRegion;
|
||||||
|
|
||||||
|
/// Download progress state.
|
||||||
|
class DownloadProgress {
|
||||||
|
final int downloaded;
|
||||||
|
final int skipped;
|
||||||
|
final int failed;
|
||||||
|
final int total;
|
||||||
|
|
||||||
|
const DownloadProgress({
|
||||||
|
this.downloaded = 0,
|
||||||
|
this.skipped = 0,
|
||||||
|
this.failed = 0,
|
||||||
|
this.total = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
int get processed => downloaded + skipped + failed;
|
||||||
|
double get percent => total == 0 ? 0 : processed / total;
|
||||||
|
bool get isComplete => total > 0 && processed >= total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A downloaded/skipped tile rectangle for map overlay.
|
||||||
|
class TileOverlay {
|
||||||
|
final double north, south, east, west;
|
||||||
|
final bool isSkipped;
|
||||||
|
|
||||||
|
const TileOverlay({
|
||||||
|
required this.north,
|
||||||
|
required this.south,
|
||||||
|
required this.east,
|
||||||
|
required this.west,
|
||||||
|
this.isSkipped = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drawing mode for polygon selection.
|
||||||
|
enum DrawingMode { none, polygon, rectangle }
|
||||||
|
|
||||||
|
/// State management for offline tile downloading.
|
||||||
|
class OfflineTilesProvider extends ChangeNotifier {
|
||||||
|
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
|
||||||
|
final TileSharingService _sharing = TileSharingService.instance;
|
||||||
|
TileDownloadService? _downloadService;
|
||||||
|
StreamSubscription<TileDownloadEvent>? _downloadSubscription;
|
||||||
|
StreamSubscription<Set<TilePeer>>? _peersSubscription;
|
||||||
|
|
||||||
|
// Drawing state
|
||||||
|
DrawingMode _drawingMode = DrawingMode.none;
|
||||||
|
final List<List<LatLng>> _polygons = [];
|
||||||
|
List<LatLng> _currentVertices = [];
|
||||||
|
LatLng? _rectangleFirstCorner;
|
||||||
|
|
||||||
|
// Download settings
|
||||||
|
int _minZoom = 8;
|
||||||
|
int _maxZoom = 14;
|
||||||
|
MapLayer _selectedLayer = MapLayer.openStreetMap;
|
||||||
|
|
||||||
|
// Download progress
|
||||||
|
bool _isDownloading = false;
|
||||||
|
DownloadProgress _progress = const DownloadProgress();
|
||||||
|
final List<TileOverlay> _tileOverlays = [];
|
||||||
|
|
||||||
|
// Cache info
|
||||||
|
int _cacheSizeBytes = 0;
|
||||||
|
|
||||||
|
// Sharing state
|
||||||
|
bool _isServerRunning = false;
|
||||||
|
Set<TilePeer> _discoveredPeers = {};
|
||||||
|
List<PeerCatalog> _peerCatalogs = [];
|
||||||
|
bool _isFetchingCatalogs = false;
|
||||||
|
bool _isSyncing = false;
|
||||||
|
String _syncStatus = '';
|
||||||
|
double _syncProgress = 0;
|
||||||
|
|
||||||
|
// Local style info
|
||||||
|
List<StyleInfo> _localStyles = [];
|
||||||
|
|
||||||
|
// Coverage overlay — which cached style's tiles to show on the map
|
||||||
|
StyleInfo? _coverageStyle;
|
||||||
|
List<TileOverlay> _coverageOverlays = [];
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
DrawingMode get drawingMode => _drawingMode;
|
||||||
|
List<List<LatLng>> get polygons => List.unmodifiable(_polygons);
|
||||||
|
List<LatLng> get currentVertices => List.unmodifiable(_currentVertices);
|
||||||
|
LatLng? get rectangleFirstCorner => _rectangleFirstCorner;
|
||||||
|
int get minZoom => _minZoom;
|
||||||
|
int get maxZoom => _maxZoom;
|
||||||
|
MapLayer get selectedLayer => _selectedLayer;
|
||||||
|
bool get isDownloading => _isDownloading;
|
||||||
|
DownloadProgress get progress => _progress;
|
||||||
|
List<TileOverlay> get tileOverlays => _tileOverlays;
|
||||||
|
int get cacheSizeBytes => _cacheSizeBytes;
|
||||||
|
bool get hasPolygons => _polygons.isNotEmpty;
|
||||||
|
bool get isServerRunning => _isServerRunning;
|
||||||
|
Set<TilePeer> get discoveredPeers => _discoveredPeers;
|
||||||
|
List<PeerCatalog> get peerCatalogs => _peerCatalogs;
|
||||||
|
bool get isFetchingCatalogs => _isFetchingCatalogs;
|
||||||
|
bool get isSyncing => _isSyncing;
|
||||||
|
String get syncStatus => _syncStatus;
|
||||||
|
double get syncProgress => _syncProgress;
|
||||||
|
List<StyleInfo> get localStyles => _localStyles;
|
||||||
|
StyleInfo? get coverageStyle => _coverageStyle;
|
||||||
|
List<TileOverlay> get coverageOverlays => _coverageOverlays;
|
||||||
|
|
||||||
|
/// Estimated tile count for the current selection.
|
||||||
|
int get estimatedTileCount {
|
||||||
|
if (_polygons.isEmpty) return 0;
|
||||||
|
return TileMathService.estimateTileCount(_polygons, _minZoom, _maxZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drawing methods
|
||||||
|
|
||||||
|
void setDrawingMode(DrawingMode mode) {
|
||||||
|
_drawingMode = mode;
|
||||||
|
_currentVertices = [];
|
||||||
|
_rectangleFirstCorner = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addVertex(LatLng point) {
|
||||||
|
if (_drawingMode == DrawingMode.polygon) {
|
||||||
|
_currentVertices = [..._currentVertices, point];
|
||||||
|
notifyListeners();
|
||||||
|
} else if (_drawingMode == DrawingMode.rectangle) {
|
||||||
|
if (_rectangleFirstCorner == null) {
|
||||||
|
_rectangleFirstCorner = point;
|
||||||
|
notifyListeners();
|
||||||
|
} else {
|
||||||
|
// Complete rectangle
|
||||||
|
final corner1 = _rectangleFirstCorner!;
|
||||||
|
final corner2 = point;
|
||||||
|
final rect = [
|
||||||
|
LatLng(corner1.latitude, corner1.longitude),
|
||||||
|
LatLng(corner1.latitude, corner2.longitude),
|
||||||
|
LatLng(corner2.latitude, corner2.longitude),
|
||||||
|
LatLng(corner2.latitude, corner1.longitude),
|
||||||
|
];
|
||||||
|
_polygons.add(rect);
|
||||||
|
_rectangleFirstCorner = null;
|
||||||
|
_drawingMode = DrawingMode.none;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void finishPolygon() {
|
||||||
|
if (_drawingMode == DrawingMode.polygon && _currentVertices.length >= 3) {
|
||||||
|
_polygons.add(List.from(_currentVertices));
|
||||||
|
_currentVertices = [];
|
||||||
|
_drawingMode = DrawingMode.none;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removePolygon(int index) {
|
||||||
|
if (index >= 0 && index < _polygons.length) {
|
||||||
|
_polygons.removeAt(index);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearPolygons() {
|
||||||
|
_polygons.clear();
|
||||||
|
_currentVertices = [];
|
||||||
|
_rectangleFirstCorner = null;
|
||||||
|
_drawingMode = DrawingMode.none;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void undoLastVertex() {
|
||||||
|
if (_currentVertices.isNotEmpty) {
|
||||||
|
_currentVertices = _currentVertices.sublist(0, _currentVertices.length - 1);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download settings
|
||||||
|
|
||||||
|
void setMinZoom(int zoom) {
|
||||||
|
_minZoom = zoom.clamp(0, 19);
|
||||||
|
if (_maxZoom < _minZoom) _maxZoom = _minZoom;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMaxZoom(int zoom) {
|
||||||
|
_maxZoom = zoom.clamp(0, 19);
|
||||||
|
if (_minZoom > _maxZoom) _minZoom = _maxZoom;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setSelectedLayer(MapLayer layer) {
|
||||||
|
_selectedLayer = layer;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download control
|
||||||
|
|
||||||
|
Future<void> startDownload() async {
|
||||||
|
if (_isDownloading || _polygons.isEmpty) return;
|
||||||
|
|
||||||
|
_isDownloading = true;
|
||||||
|
_progress = const DownloadProgress();
|
||||||
|
_tileOverlays.clear();
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
_downloadService = TileDownloadService();
|
||||||
|
final stream = _downloadService!.downloadTiles(
|
||||||
|
polygons: _polygons,
|
||||||
|
minZoom: _minZoom,
|
||||||
|
maxZoom: _maxZoom,
|
||||||
|
urlTemplate: _selectedLayer.urlTemplate,
|
||||||
|
displayName: _selectedLayer.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
await for (final event in stream) {
|
||||||
|
switch (event) {
|
||||||
|
case TileDownloadStarted(:final totalTiles):
|
||||||
|
_progress = DownloadProgress(total: totalTiles);
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
case TileDownloaded(:final north, :final south, :final east, :final west):
|
||||||
|
_progress = DownloadProgress(
|
||||||
|
downloaded: _progress.downloaded + 1,
|
||||||
|
skipped: _progress.skipped,
|
||||||
|
failed: _progress.failed,
|
||||||
|
total: _progress.total,
|
||||||
|
);
|
||||||
|
_addOverlay(TileOverlay(
|
||||||
|
north: north, south: south, east: east, west: west,
|
||||||
|
));
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
case TileSkipped():
|
||||||
|
_progress = DownloadProgress(
|
||||||
|
downloaded: _progress.downloaded,
|
||||||
|
skipped: _progress.skipped + 1,
|
||||||
|
failed: _progress.failed,
|
||||||
|
total: _progress.total,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
case TileBatchSkipped(:final count):
|
||||||
|
_progress = DownloadProgress(
|
||||||
|
downloaded: _progress.downloaded,
|
||||||
|
skipped: _progress.skipped + count,
|
||||||
|
failed: _progress.failed,
|
||||||
|
total: _progress.total,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
case TileFailed():
|
||||||
|
_progress = DownloadProgress(
|
||||||
|
downloaded: _progress.downloaded,
|
||||||
|
skipped: _progress.skipped,
|
||||||
|
failed: _progress.failed + 1,
|
||||||
|
total: _progress.total,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
case TileDownloadComplete():
|
||||||
|
_isDownloading = false;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
case TileDownloadCancelled():
|
||||||
|
_isDownloading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDownloading = false;
|
||||||
|
_downloadService?.dispose();
|
||||||
|
_downloadService = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancelDownload() {
|
||||||
|
_downloadService?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearOverlays() {
|
||||||
|
_tileOverlays.clear();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addOverlay(TileOverlay overlay) {
|
||||||
|
_tileOverlays.add(overlay);
|
||||||
|
// Limit overlays to prevent OOM
|
||||||
|
if (_tileOverlays.length > 500) {
|
||||||
|
_tileOverlays.removeRange(0, _tileOverlays.length - 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache management
|
||||||
|
|
||||||
|
Future<void> refreshCacheSize() async {
|
||||||
|
_cacheSizeBytes = await _cache.getCacheSize();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteStyle(StyleInfo style) async {
|
||||||
|
if (_coverageStyle?.hash == style.hash) hideCoverage();
|
||||||
|
await _cache.deleteStyle(style.hash);
|
||||||
|
await refreshCacheSize();
|
||||||
|
await refreshLocalStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearCache() async {
|
||||||
|
hideCoverage();
|
||||||
|
await _cache.clearCache();
|
||||||
|
_cacheSizeBytes = 0;
|
||||||
|
_localStyles = [];
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coverage overlay — show cached tile bounds on the map
|
||||||
|
|
||||||
|
/// Show the coverage of a cached style on the map.
|
||||||
|
/// Loads tile coordinates from the manifest and converts to bounds.
|
||||||
|
Future<void> showCoverage(StyleInfo style) async {
|
||||||
|
if (_coverageStyle?.hash == style.hash) {
|
||||||
|
// Toggle off if same style tapped again
|
||||||
|
hideCoverage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_coverageStyle = style;
|
||||||
|
_coverageOverlays = [];
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final manifest = await _cache.loadManifest(style.hash);
|
||||||
|
|
||||||
|
// Convert manifest keys to tile bound overlays
|
||||||
|
final overlays = <TileOverlay>[];
|
||||||
|
for (final key in manifest) {
|
||||||
|
final parts = key.split('/');
|
||||||
|
if (parts.length != 3) continue;
|
||||||
|
final z = int.tryParse(parts[0]);
|
||||||
|
final x = int.tryParse(parts[1]);
|
||||||
|
final y = int.tryParse(parts[2]);
|
||||||
|
if (z == null || x == null || y == null) continue;
|
||||||
|
|
||||||
|
final bounds = TileMathService.tileBounds(x, y, z);
|
||||||
|
overlays.add(TileOverlay(
|
||||||
|
north: bounds.north,
|
||||||
|
south: bounds.south,
|
||||||
|
east: bounds.east,
|
||||||
|
west: bounds.west,
|
||||||
|
isSkipped: true, // green color
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
_coverageOverlays = overlays;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void hideCoverage() {
|
||||||
|
_coverageStyle = null;
|
||||||
|
_coverageOverlays = [];
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sharing controls
|
||||||
|
|
||||||
|
Future<void> toggleServer() async {
|
||||||
|
if (_isServerRunning) {
|
||||||
|
await _sharing.stopServer();
|
||||||
|
_isServerRunning = false;
|
||||||
|
} else {
|
||||||
|
await _sharing.startServer();
|
||||||
|
_isServerRunning = _sharing.isRunning;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> startPeerDiscovery() async {
|
||||||
|
_peersSubscription?.cancel();
|
||||||
|
_peersSubscription = _sharing.peersStream.listen((peers) {
|
||||||
|
_discoveredPeers = peers;
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
await _sharing.startDiscovery();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stopPeerDiscovery() async {
|
||||||
|
_peersSubscription?.cancel();
|
||||||
|
_peersSubscription = null;
|
||||||
|
await _sharing.stopPeerDiscovery();
|
||||||
|
_discoveredPeers = {};
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addManualPeer(String ipAddress) {
|
||||||
|
_sharing.addManualPeer(ipAddress);
|
||||||
|
_discoveredPeers = _sharing.discoveredPeers;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void removePeer(TilePeer peer) {
|
||||||
|
_sharing.removePeer(peer);
|
||||||
|
_discoveredPeers = _sharing.discoveredPeers;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peer catalog & P2P sync
|
||||||
|
|
||||||
|
/// Refresh local style info.
|
||||||
|
Future<void> refreshLocalStyles() async {
|
||||||
|
_localStyles = await _cache.listStylesDetailed();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch catalogs from all discovered peers to see what they have.
|
||||||
|
Future<void> refreshPeerCatalogs() async {
|
||||||
|
_isFetchingCatalogs = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
_peerCatalogs = await _sharing.fetchAllPeerCatalogs();
|
||||||
|
_isFetchingCatalogs = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync a style from one or more peers that have it.
|
||||||
|
/// Finds all peers offering [styleHash] and pulls missing tiles.
|
||||||
|
Future<void> syncStyleFromPeers(StyleInfo style) async {
|
||||||
|
if (_isSyncing) return;
|
||||||
|
|
||||||
|
// Find all peers that have this style
|
||||||
|
final peersWithStyle = <TilePeer>[];
|
||||||
|
for (final catalog in _peerCatalogs) {
|
||||||
|
if (catalog.styles.any((s) => s.hash == style.hash)) {
|
||||||
|
peersWithStyle.add(catalog.peer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (peersWithStyle.isEmpty) return;
|
||||||
|
|
||||||
|
_isSyncing = true;
|
||||||
|
_syncStatus = 'Starting sync of ${style.displayName}...';
|
||||||
|
_syncProgress = 0;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
final stream = _sharing.syncStyleFromPeers(
|
||||||
|
peers: peersWithStyle,
|
||||||
|
styleHash: style.hash,
|
||||||
|
styleMeta: style,
|
||||||
|
);
|
||||||
|
|
||||||
|
await for (final event in stream) {
|
||||||
|
switch (event) {
|
||||||
|
case PeerSyncStarted(:final totalTiles):
|
||||||
|
_syncStatus = 'Syncing ${style.displayName}: 0/$totalTiles tiles';
|
||||||
|
_syncProgress = 0;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
case PeerSyncTileDownloaded(:final downloaded, :final total):
|
||||||
|
_syncStatus =
|
||||||
|
'Syncing ${style.displayName}: $downloaded/$total tiles';
|
||||||
|
_syncProgress = total > 0 ? downloaded / total : 0;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
case PeerSyncTileSkipped(:final skipped, :final total):
|
||||||
|
_syncProgress = total > 0 ? skipped / total : 0;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
case PeerSyncComplete(:final downloaded, :final skipped, :final failed):
|
||||||
|
_syncStatus =
|
||||||
|
'Done! $downloaded new, $skipped cached, $failed failed';
|
||||||
|
_isSyncing = false;
|
||||||
|
notifyListeners();
|
||||||
|
await refreshCacheSize();
|
||||||
|
await refreshLocalStyles();
|
||||||
|
|
||||||
|
case PeerSyncCancelled():
|
||||||
|
_syncStatus = 'Sync cancelled';
|
||||||
|
_isSyncing = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancelSync() {
|
||||||
|
_sharing.cancelSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Presets — load a previously downloaded region for quick re-download
|
||||||
|
|
||||||
|
/// Load a saved download region as the current selection.
|
||||||
|
/// Restores polygons, zoom range, and map layer.
|
||||||
|
void loadPreset(StyleInfo style) {
|
||||||
|
if (style.region == null) return;
|
||||||
|
|
||||||
|
final region = style.region!;
|
||||||
|
|
||||||
|
// Restore polygons
|
||||||
|
_polygons.clear();
|
||||||
|
for (final polyData in region.polygons) {
|
||||||
|
final poly = polyData.map((v) => LatLng(v[0], v[1])).toList();
|
||||||
|
if (poly.length >= 3) _polygons.add(poly);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore zoom range
|
||||||
|
_minZoom = region.minZoom;
|
||||||
|
_maxZoom = region.maxZoom;
|
||||||
|
|
||||||
|
// Try to find the matching map layer
|
||||||
|
if (style.urlTemplate.isNotEmpty) {
|
||||||
|
final matchingLayer = MapLayer.allLayers.where(
|
||||||
|
(l) => l.urlTemplate == style.urlTemplate,
|
||||||
|
);
|
||||||
|
if (matchingLayer.isNotEmpty) {
|
||||||
|
_selectedLayer = matchingLayer.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentVertices = [];
|
||||||
|
_rectangleFirstCorner = null;
|
||||||
|
_drawingMode = DrawingMode.none;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_downloadSubscription?.cancel();
|
||||||
|
_downloadService?.dispose();
|
||||||
|
_peersSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
854
lib/providers/sensors_provider.dart
Normal file
854
lib/providers/sensors_provider.dart
Normal file
@@ -0,0 +1,854 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
import '../services/profiles_feature_service.dart';
|
||||||
|
import '../widgets/sensors/sensor_telemetry_card.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 Duration selfAutoRefreshInterval = Duration(seconds: 30);
|
||||||
|
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
||||||
|
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
||||||
|
static const String _fieldSpanKey = 'sensor_field_spans';
|
||||||
|
static const String _metricLabelKey = 'sensor_metric_labels';
|
||||||
|
static const String _metricOrderKey = 'sensor_metric_order';
|
||||||
|
static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes';
|
||||||
|
static const List<int> supportedAutoRefreshIntervals = <int>[
|
||||||
|
0,
|
||||||
|
5,
|
||||||
|
15,
|
||||||
|
30,
|
||||||
|
60,
|
||||||
|
360,
|
||||||
|
720,
|
||||||
|
1440,
|
||||||
|
];
|
||||||
|
static const Set<String> _defaultVisibleFields = <String>{
|
||||||
|
'voltage',
|
||||||
|
'battery',
|
||||||
|
'temperature',
|
||||||
|
'humidity',
|
||||||
|
'pressure',
|
||||||
|
'gps',
|
||||||
|
};
|
||||||
|
static const List<String> _defaultMetricOrder = <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>>{};
|
||||||
|
final Map<String, Map<String, String>> _metricLabelsBySensor =
|
||||||
|
<String, Map<String, String>>{};
|
||||||
|
final Map<String, List<String>> _metricOrderBySensor =
|
||||||
|
<String, List<String>>{};
|
||||||
|
final Map<String, int> _autoRefreshMinutesBySensor = <String, int>{};
|
||||||
|
final Map<String, DateTime> _lastRefreshAttemptAt = <String, DateTime>{};
|
||||||
|
bool _isLoaded = false;
|
||||||
|
bool _isRefreshingAll = false;
|
||||||
|
bool _isRunningAutoRefreshTick = false;
|
||||||
|
|
||||||
|
SensorsProvider() {
|
||||||
|
unawaited(_loadWatchedSensors());
|
||||||
|
}
|
||||||
|
|
||||||
|
String _key(String baseKey) => ProfileStorageScope.scopedKey(baseKey);
|
||||||
|
|
||||||
|
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(_key(_watchedSensorsKey)) ?? <String>[];
|
||||||
|
final storedMetricsJson = prefs.getString(_key(_visibleSensorMetricsKey));
|
||||||
|
final storedSpansJson = prefs.getString(_key(_fieldSpanKey));
|
||||||
|
final storedLabelsJson = prefs.getString(_key(_metricLabelKey));
|
||||||
|
final storedOrderJson = prefs.getString(_key(_metricOrderKey));
|
||||||
|
final storedAutoRefreshJson = prefs.getString(
|
||||||
|
_key(_autoRefreshMinutesKey),
|
||||||
|
);
|
||||||
|
_watchedSensorKeys
|
||||||
|
..clear()
|
||||||
|
..addAll(stored);
|
||||||
|
_refreshStates.clear();
|
||||||
|
_refreshStateUpdatedAt.clear();
|
||||||
|
_visibleFieldsBySensor.clear();
|
||||||
|
_fieldSpansBySensor.clear();
|
||||||
|
_metricLabelsBySensor.clear();
|
||||||
|
_metricOrderBySensor.clear();
|
||||||
|
_autoRefreshMinutesBySensor.clear();
|
||||||
|
_lastRefreshAttemptAt.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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (storedLabelsJson != null && storedLabelsJson.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(storedLabelsJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
_metricLabelsBySensor[entry.key] =
|
||||||
|
(entry.value as Map<String, dynamic>).map(
|
||||||
|
(key, value) => MapEntry(key, value as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (storedOrderJson != null && storedOrderJson.isNotEmpty) {
|
||||||
|
final decoded = jsonDecode(storedOrderJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
_metricOrderBySensor[entry.key] = (entry.value as List<dynamic>)
|
||||||
|
.cast<String>()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (storedAutoRefreshJson != null && storedAutoRefreshJson.isNotEmpty) {
|
||||||
|
final decoded =
|
||||||
|
jsonDecode(storedAutoRefreshJson) as Map<String, dynamic>;
|
||||||
|
for (final entry in decoded.entries) {
|
||||||
|
final minutes = _normalizeAutoRefreshMinutes(
|
||||||
|
(entry.value as num).toInt(),
|
||||||
|
);
|
||||||
|
if (minutes > 0) {
|
||||||
|
_autoRefreshMinutesBySensor[entry.key] = minutes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_autoRefreshMinutesBySensor.removeWhere(
|
||||||
|
(key, _) => !_watchedSensorKeys.contains(key),
|
||||||
|
);
|
||||||
|
for (final key in _watchedSensorKeys) {
|
||||||
|
_visibleFieldsBySensor.putIfAbsent(
|
||||||
|
key,
|
||||||
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
|
);
|
||||||
|
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
|
||||||
|
_metricLabelsBySensor.putIfAbsent(key, () => <String, String>{});
|
||||||
|
_metricOrderBySensor.putIfAbsent(
|
||||||
|
key,
|
||||||
|
() => List<String>.from(_defaultMetricOrder),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error loading watched sensors: $e');
|
||||||
|
} finally {
|
||||||
|
_isLoaded = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reloadProfileScopedState() async {
|
||||||
|
_isLoaded = false;
|
||||||
|
await _loadWatchedSensors();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistWatchedSensors() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(_key(_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(
|
||||||
|
_key(_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(
|
||||||
|
_key(_fieldSpanKey),
|
||||||
|
jsonEncode(_fieldSpansBySensor),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor field spans: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistMetricLabels() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
_key(_metricLabelKey),
|
||||||
|
jsonEncode(_metricLabelsBySensor),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor metric labels: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistMetricOrder() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
_key(_metricOrderKey),
|
||||||
|
jsonEncode(_metricOrderBySensor),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor metric order: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistAutoRefreshMinutes() async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
_key(_autoRefreshMinutesKey),
|
||||||
|
jsonEncode(_autoRefreshMinutesBySensor),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Error saving sensor auto refresh minutes: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
|
||||||
|
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
|
||||||
|
);
|
||||||
|
|
||||||
|
Set<String> effectiveVisibleFieldsFor(
|
||||||
|
String publicKeyHex,
|
||||||
|
Iterable<String> availableFieldKeys,
|
||||||
|
) {
|
||||||
|
final storedVisibleFields =
|
||||||
|
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields;
|
||||||
|
if (!_shouldAutoIncludeAvailableFields(publicKeyHex, storedVisibleFields)) {
|
||||||
|
return Set<String>.unmodifiable(storedVisibleFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Set<String>.unmodifiable(<String>{
|
||||||
|
...storedVisibleFields,
|
||||||
|
...availableFieldKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> metricOrderFor(
|
||||||
|
String publicKeyHex,
|
||||||
|
Iterable<String> availableFieldKeys,
|
||||||
|
) {
|
||||||
|
final available = availableFieldKeys.toList();
|
||||||
|
final availableSet = available.toSet();
|
||||||
|
final ordered = <String>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
final stored =
|
||||||
|
_metricOrderBySensor[publicKeyHex] ??
|
||||||
|
List<String>.from(_defaultMetricOrder);
|
||||||
|
|
||||||
|
for (final fieldKey in stored) {
|
||||||
|
if (availableSet.contains(fieldKey) && seen.add(fieldKey)) {
|
||||||
|
ordered.add(fieldKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final fieldKey in available) {
|
||||||
|
if (seen.add(fieldKey)) {
|
||||||
|
ordered.add(fieldKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List<String>.unmodifiable(ordered);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> labelOverridesFor(String publicKeyHex) =>
|
||||||
|
Map<String, String>.unmodifiable(
|
||||||
|
_metricLabelsBySensor[publicKeyHex] ?? const <String, String>{},
|
||||||
|
);
|
||||||
|
|
||||||
|
String? labelOverrideFor(String publicKeyHex, String fieldKey) =>
|
||||||
|
_metricLabelsBySensor[publicKeyHex]?[fieldKey];
|
||||||
|
|
||||||
|
int autoRefreshMinutesFor(String publicKeyHex) =>
|
||||||
|
_autoRefreshMinutesBySensor[publicKeyHex] ?? 0;
|
||||||
|
|
||||||
|
Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async {
|
||||||
|
final normalizedMinutes = _normalizeAutoRefreshMinutes(minutes);
|
||||||
|
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
|
||||||
|
if (currentMinutes == normalizedMinutes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedMinutes == 0) {
|
||||||
|
_autoRefreshMinutesBySensor.remove(publicKeyHex);
|
||||||
|
} else {
|
||||||
|
_autoRefreshMinutesBySensor[publicKeyHex] = normalizedMinutes;
|
||||||
|
}
|
||||||
|
await _persistAutoRefreshMinutes();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _normalizeAutoRefreshMinutes(int minutes) {
|
||||||
|
if (minutes <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportedAutoRefreshIntervals.contains(minutes)) {
|
||||||
|
return minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
final positiveIntervals = supportedAutoRefreshIntervals.where(
|
||||||
|
(value) => value > 0,
|
||||||
|
);
|
||||||
|
var bestMatch = positiveIntervals.first;
|
||||||
|
var bestDistance = (minutes - bestMatch).abs();
|
||||||
|
|
||||||
|
for (final interval in positiveIntervals.skip(1)) {
|
||||||
|
final distance = (minutes - interval).abs();
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestMatch = interval;
|
||||||
|
bestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestMatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> dueAutoRefreshSensorKeys({DateTime? now}) {
|
||||||
|
final refreshTime = now ?? DateTime.now();
|
||||||
|
final dueKeys = <String>[];
|
||||||
|
|
||||||
|
for (final key in _watchedSensorKeys) {
|
||||||
|
final minutes = autoRefreshMinutesFor(key);
|
||||||
|
if (minutes <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastRefreshAt = _lastRefreshAttemptAt[key];
|
||||||
|
if (lastRefreshAt == null ||
|
||||||
|
refreshTime.difference(lastRefreshAt) >= Duration(minutes: minutes)) {
|
||||||
|
dueKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return List<String>.unmodifiable(dueKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isRefreshDueForInterval(
|
||||||
|
String publicKeyHex, {
|
||||||
|
required Duration interval,
|
||||||
|
required DateTime refreshTime,
|
||||||
|
}) {
|
||||||
|
if (interval <= Duration.zero) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastRefreshAt = _lastRefreshAttemptAt[publicKeyHex];
|
||||||
|
return lastRefreshAt == null ||
|
||||||
|
refreshTime.difference(lastRefreshAt) >= interval;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggleMetric(
|
||||||
|
String publicKeyHex,
|
||||||
|
String fieldKey,
|
||||||
|
bool visible,
|
||||||
|
) async {
|
||||||
|
final visibleFields = _visibleFieldsBySensor.putIfAbsent(
|
||||||
|
publicKeyHex,
|
||||||
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
|
);
|
||||||
|
final metricOrder = _metricOrderBySensor.putIfAbsent(
|
||||||
|
publicKeyHex,
|
||||||
|
() => List<String>.from(_defaultMetricOrder),
|
||||||
|
);
|
||||||
|
var shouldPersistOrder = false;
|
||||||
|
if (visible) {
|
||||||
|
visibleFields.add(fieldKey);
|
||||||
|
if (!metricOrder.contains(fieldKey)) {
|
||||||
|
metricOrder.add(fieldKey);
|
||||||
|
shouldPersistOrder = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
visibleFields.remove(fieldKey);
|
||||||
|
}
|
||||||
|
await _persistVisibleMetrics();
|
||||||
|
if (shouldPersistOrder) {
|
||||||
|
await _persistMetricOrder();
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setMetricLabel(
|
||||||
|
String publicKeyHex,
|
||||||
|
String fieldKey,
|
||||||
|
String? label,
|
||||||
|
) async {
|
||||||
|
final sensorLabels = _metricLabelsBySensor.putIfAbsent(
|
||||||
|
publicKeyHex,
|
||||||
|
() => <String, String>{},
|
||||||
|
);
|
||||||
|
final trimmed = label?.trim();
|
||||||
|
if (trimmed == null || trimmed.isEmpty) {
|
||||||
|
sensorLabels.remove(fieldKey);
|
||||||
|
} else {
|
||||||
|
sensorLabels[fieldKey] = trimmed;
|
||||||
|
}
|
||||||
|
if (sensorLabels.isEmpty) {
|
||||||
|
_metricLabelsBySensor.remove(publicKeyHex);
|
||||||
|
}
|
||||||
|
await _persistMetricLabels();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> moveMetric(
|
||||||
|
String publicKeyHex, {
|
||||||
|
required List<String> availableFieldKeys,
|
||||||
|
required int oldIndex,
|
||||||
|
required int newIndex,
|
||||||
|
}) async {
|
||||||
|
if (oldIndex < 0 ||
|
||||||
|
newIndex < 0 ||
|
||||||
|
oldIndex >= availableFieldKeys.length ||
|
||||||
|
newIndex >= availableFieldKeys.length ||
|
||||||
|
oldIndex == newIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final reordered = List<String>.from(
|
||||||
|
metricOrderFor(publicKeyHex, availableFieldKeys),
|
||||||
|
);
|
||||||
|
final fieldKey = reordered.removeAt(oldIndex);
|
||||||
|
reordered.insert(newIndex, fieldKey);
|
||||||
|
|
||||||
|
final storedTail =
|
||||||
|
(_metricOrderBySensor[publicKeyHex] ?? _defaultMetricOrder).where(
|
||||||
|
(key) => !reordered.contains(key),
|
||||||
|
);
|
||||||
|
_metricOrderBySensor[publicKeyHex] = <String>[...reordered, ...storedTail];
|
||||||
|
await _persistMetricOrder();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isWatched(String publicKeyHex) =>
|
||||||
|
_watchedSensorKeys.contains(publicKeyHex);
|
||||||
|
|
||||||
|
Contact? selfContact(
|
||||||
|
ContactsProvider contactsProvider,
|
||||||
|
ConnectionProvider connectionProvider,
|
||||||
|
) {
|
||||||
|
final selfKey = connectionProvider.deviceInfo.publicKey;
|
||||||
|
if (selfKey == null || selfKey.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final existing = contactsProvider.findContactByKey(
|
||||||
|
Uint8List.fromList(selfKey),
|
||||||
|
);
|
||||||
|
final selfTelemetry = contactsProvider.selfTelemetry;
|
||||||
|
if (existing != null) {
|
||||||
|
return selfTelemetry == null
|
||||||
|
? existing
|
||||||
|
: existing.copyWith(telemetry: selfTelemetry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _buildSelfCandidate(connectionProvider, telemetry: selfTelemetry);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addSensor(Contact contact) async {
|
||||||
|
if (!contact.isChat && !contact.isRepeater && !contact.isSensor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_watchedSensorKeys.add(contact.publicKeyHex);
|
||||||
|
final initialVisibleFields = _initialVisibleFieldsForContact(contact);
|
||||||
|
await _persistWatchedSensors();
|
||||||
|
_visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields;
|
||||||
|
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
||||||
|
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
|
||||||
|
_metricOrderBySensor[contact.publicKeyHex] = metricOrderFor(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
initialVisibleFields,
|
||||||
|
);
|
||||||
|
await _persistVisibleMetrics();
|
||||||
|
await _persistFieldSpans();
|
||||||
|
await _persistMetricLabels();
|
||||||
|
await _persistMetricOrder();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldAutoIncludeAvailableFields(
|
||||||
|
String publicKeyHex,
|
||||||
|
Set<String> storedVisibleFields,
|
||||||
|
) {
|
||||||
|
if (!setEquals(storedVisibleFields, _defaultVisibleFields)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final storedOrder = _metricOrderBySensor[publicKeyHex];
|
||||||
|
if (storedOrder != null && !listEquals(storedOrder, _defaultMetricOrder)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final storedLabels = _metricLabelsBySensor[publicKeyHex];
|
||||||
|
if (storedLabels != null && storedLabels.isNotEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final storedSpans = _fieldSpansBySensor[publicKeyHex];
|
||||||
|
if (storedSpans != null &&
|
||||||
|
!(storedSpans.length == 1 && storedSpans['gps'] == 2)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> _initialVisibleFieldsForContact(Contact contact) {
|
||||||
|
return <String>{..._defaultVisibleFields, ...sensorMetricKeysFor(contact)};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeSensor(String publicKeyHex) async {
|
||||||
|
_watchedSensorKeys.remove(publicKeyHex);
|
||||||
|
_refreshStates.remove(publicKeyHex);
|
||||||
|
_refreshStateUpdatedAt.remove(publicKeyHex);
|
||||||
|
_visibleFieldsBySensor.remove(publicKeyHex);
|
||||||
|
_fieldSpansBySensor.remove(publicKeyHex);
|
||||||
|
_metricLabelsBySensor.remove(publicKeyHex);
|
||||||
|
_metricOrderBySensor.remove(publicKeyHex);
|
||||||
|
_autoRefreshMinutesBySensor.remove(publicKeyHex);
|
||||||
|
_lastRefreshAttemptAt.remove(publicKeyHex);
|
||||||
|
await _persistWatchedSensors();
|
||||||
|
await _persistVisibleMetrics();
|
||||||
|
await _persistFieldSpans();
|
||||||
|
await _persistMetricLabels();
|
||||||
|
await _persistMetricOrder();
|
||||||
|
await _persistAutoRefreshMinutes();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reorderSensors(int oldIndex, int newIndex) async {
|
||||||
|
if (_watchedSensorKeys.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldIndex < 0 ||
|
||||||
|
oldIndex >= _watchedSensorKeys.length ||
|
||||||
|
newIndex < 0 ||
|
||||||
|
newIndex > _watchedSensorKeys.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetIndex = newIndex;
|
||||||
|
if (oldIndex < targetIndex) {
|
||||||
|
targetIndex -= 1;
|
||||||
|
}
|
||||||
|
if (oldIndex == targetIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final movedKey = _watchedSensorKeys.removeAt(oldIndex);
|
||||||
|
_watchedSensorKeys.insert(targetIndex, movedKey);
|
||||||
|
await _persistWatchedSensors();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Contact> availableCandidates(
|
||||||
|
ContactsProvider contactsProvider, {
|
||||||
|
ConnectionProvider? connectionProvider,
|
||||||
|
}) {
|
||||||
|
final candidates = <Contact>[
|
||||||
|
...contactsProvider.chatContacts,
|
||||||
|
...contactsProvider.repeaters,
|
||||||
|
...contactsProvider.sensorContacts,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add "myself" (own device) as a candidate if it has a public key
|
||||||
|
if (connectionProvider != null) {
|
||||||
|
final self = selfContact(contactsProvider, connectionProvider);
|
||||||
|
if (self != null &&
|
||||||
|
!candidates.any((c) => c.publicKeyHex == self.publicKeyHex)) {
|
||||||
|
candidates.insert(0, self);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
|
||||||
|
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||||
|
return candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> displaySensorKeys({
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
}) {
|
||||||
|
if (_watchedSensorKeys.isNotEmpty) {
|
||||||
|
return List<String>.unmodifiable(_watchedSensorKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
final self = selfContact(contactsProvider, connectionProvider);
|
||||||
|
if (self == null) {
|
||||||
|
return const <String>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return <String>[self.publicKeyHex];
|
||||||
|
}
|
||||||
|
|
||||||
|
String? displaySelfKey({
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
}) {
|
||||||
|
final self = selfContact(contactsProvider, connectionProvider);
|
||||||
|
if (self == null || _watchedSensorKeys.contains(self.publicKeyHex)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return self.publicKeyHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact? contactForDisplay(
|
||||||
|
String publicKeyHex, {
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
}) {
|
||||||
|
for (final entry in contactsProvider.contacts) {
|
||||||
|
if (entry.publicKeyHex == publicKeyHex) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final self = selfContact(contactsProvider, connectionProvider);
|
||||||
|
if (self?.publicKeyHex == publicKeyHex) {
|
||||||
|
return self;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshAll({
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
}) async {
|
||||||
|
if (_isRefreshingAll) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final self = selfContact(contactsProvider, connectionProvider);
|
||||||
|
final keysToRefresh = <String>[
|
||||||
|
if (self != null) self.publicKeyHex,
|
||||||
|
..._watchedSensorKeys.where(
|
||||||
|
(key) => self == null || key != self.publicKeyHex,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (keysToRefresh.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isRefreshingAll = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (final key in keysToRefresh) {
|
||||||
|
await refreshSensor(
|
||||||
|
publicKeyHex: key,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isRefreshingAll = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshSensor({
|
||||||
|
required String publicKeyHex,
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
DateTime? requestedAt,
|
||||||
|
}) async {
|
||||||
|
if (stateFor(publicKeyHex) == SensorRefreshState.refreshing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
|
||||||
|
|
||||||
|
final contact = contactForDisplay(
|
||||||
|
publicKeyHex,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshDueSensors({
|
||||||
|
required ContactsProvider contactsProvider,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
DateTime? now,
|
||||||
|
}) async {
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected ||
|
||||||
|
_isRefreshingAll ||
|
||||||
|
_isRunningAutoRefreshTick) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final refreshTime = now ?? DateTime.now();
|
||||||
|
final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime).toList();
|
||||||
|
final self = selfContact(contactsProvider, connectionProvider);
|
||||||
|
if (self != null &&
|
||||||
|
!dueKeys.contains(self.publicKeyHex) &&
|
||||||
|
_isRefreshDueForInterval(
|
||||||
|
self.publicKeyHex,
|
||||||
|
interval: selfAutoRefreshInterval,
|
||||||
|
refreshTime: refreshTime,
|
||||||
|
)) {
|
||||||
|
dueKeys.insert(0, self.publicKeyHex);
|
||||||
|
}
|
||||||
|
if (dueKeys.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isRunningAutoRefreshTick = true;
|
||||||
|
try {
|
||||||
|
for (final key in dueKeys) {
|
||||||
|
await refreshSensor(
|
||||||
|
publicKeyHex: key,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
requestedAt: refreshTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_isRunningAutoRefreshTick = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
Contact? _buildSelfCandidate(
|
||||||
|
ConnectionProvider connectionProvider, {
|
||||||
|
ContactTelemetry? telemetry,
|
||||||
|
}) {
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
final selfKey = deviceInfo.publicKey;
|
||||||
|
if (selfKey == null || selfKey.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
publicKey: Uint8List.fromList(selfKey),
|
||||||
|
type: ContactType.chat,
|
||||||
|
flags: 0,
|
||||||
|
outPathLen: 0,
|
||||||
|
outPath: Uint8List(64),
|
||||||
|
advName: deviceInfo.selfName ?? 'My Device',
|
||||||
|
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
advLat: deviceInfo.advLat ?? 0,
|
||||||
|
advLon: deviceInfo.advLon ?? 0,
|
||||||
|
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
telemetry: telemetry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
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/contact.dart';
|
import '../models/contact.dart';
|
||||||
|
import '../services/profiles_feature_service.dart';
|
||||||
|
import 'helpers/raw_session_retransmit.dart';
|
||||||
import '../utils/voice_message_parser.dart';
|
import '../utils/voice_message_parser.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';
|
||||||
@@ -13,6 +16,8 @@ class VoiceSession {
|
|||||||
final VoicePacketMode mode;
|
final VoicePacketMode mode;
|
||||||
final int total;
|
final int total;
|
||||||
final List<VoicePacket?> packets; // indexed by packet.index
|
final List<VoicePacket?> packets; // indexed by packet.index
|
||||||
|
DateTime? firstPacketAt;
|
||||||
|
DateTime? lastPacketAt;
|
||||||
|
|
||||||
VoiceSession({
|
VoiceSession({
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
@@ -23,6 +28,19 @@ class VoiceSession {
|
|||||||
int get receivedCount => packets.where((p) => p != null).length;
|
int get receivedCount => packets.where((p) => p != null).length;
|
||||||
bool get isComplete => receivedCount == total;
|
bool get isComplete => receivedCount == total;
|
||||||
|
|
||||||
|
Duration? estimateRemaining() {
|
||||||
|
if (isComplete) return Duration.zero;
|
||||||
|
if (firstPacketAt == null || lastPacketAt == null) return null;
|
||||||
|
if (receivedCount < 2) return null;
|
||||||
|
|
||||||
|
final elapsedMs = lastPacketAt!.difference(firstPacketAt!).inMilliseconds;
|
||||||
|
if (elapsedMs <= 0) return null;
|
||||||
|
final avgMsPerPacket = elapsedMs / (receivedCount - 1);
|
||||||
|
final remaining = total - receivedCount;
|
||||||
|
if (remaining <= 0) return Duration.zero;
|
||||||
|
return Duration(milliseconds: (avgMsPerPacket * remaining).round());
|
||||||
|
}
|
||||||
|
|
||||||
/// Total estimated audio duration in seconds (sum of all received packets).
|
/// Total estimated audio duration in seconds (sum of all received packets).
|
||||||
double get estimatedDurationSeconds {
|
double get estimatedDurationSeconds {
|
||||||
var ms = 0;
|
var ms = 0;
|
||||||
@@ -43,6 +61,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;
|
||||||
@@ -74,6 +93,9 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
_restorePersistedVoiceData();
|
_restorePersistedVoiceData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _storageKey() =>
|
||||||
|
ProfileStorageScope.scopedKey(_voiceSessionsStorageKey);
|
||||||
|
|
||||||
// ── Session accessors ────────────────────────────────────────────────────
|
// ── Session accessors ────────────────────────────────────────────────────
|
||||||
|
|
||||||
VoiceSession? session(String sessionId) => _sessions[sessionId];
|
VoiceSession? session(String sessionId) => _sessions[sessionId];
|
||||||
@@ -93,6 +115,10 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
|
|
||||||
bool hasOutgoingSession(String sessionId) =>
|
bool hasOutgoingSession(String sessionId) =>
|
||||||
_outgoingSessions.containsKey(sessionId);
|
_outgoingSessions.containsKey(sessionId);
|
||||||
|
Duration? estimateRemainingTransferTime(String sessionId) =>
|
||||||
|
_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];
|
||||||
@@ -104,23 +130,56 @@ 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) {
|
||||||
|
final wasMissing = session.packets[packet.index] == null;
|
||||||
session.packets[packet.index] = packet;
|
session.packets[packet.index] = packet;
|
||||||
|
if (wasMissing) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
session.firstPacketAt ??= now;
|
||||||
|
session.lastPacketAt = now;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final justComplete = session.isComplete;
|
final justComplete = session.isComplete;
|
||||||
@@ -129,6 +188,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;
|
||||||
@@ -145,49 +262,28 @@ 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;
|
||||||
}
|
}
|
||||||
if (sendRawPacketCallback == null) {
|
return serveCachedSessionFragments<VoicePacket>(
|
||||||
debugPrint('⚠️ [VoiceProvider] sendRawPacketCallback is not set');
|
providerLabel: 'VoiceProvider',
|
||||||
return false;
|
sessionId: sessionId,
|
||||||
}
|
requester: requester,
|
||||||
if (requester.outPathLen < 0) {
|
fragments: packets,
|
||||||
debugPrint(
|
maxDirectPayloadHops: maxDirectPayloadHops,
|
||||||
'⚠️ [VoiceProvider] Requester ${requester.advName} has no direct path',
|
indexOf: (packet) => packet.index,
|
||||||
);
|
encodeBinary: (packet) => packet.encodeBinary(),
|
||||||
return false;
|
sendRawPacket: sendRawPacketCallback,
|
||||||
}
|
requestedIndices: requestedIndices,
|
||||||
if (requester.outPathLen > maxDirectPayloadHops) {
|
);
|
||||||
debugPrint(
|
|
||||||
'⚠️ [VoiceProvider] Requester ${requester.advName} is too far: ${requester.outPathLen} hops (max $maxDirectPayloadHops)',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final packet in cached.packets) {
|
|
||||||
if (requestedIndices != null &&
|
|
||||||
!requestedIndices.contains(packet.index)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await sendRawPacketCallback!(
|
|
||||||
contactPath: requester.outPath,
|
|
||||||
contactPathLen: requester.outPathLen,
|
|
||||||
payload: packet.encodeBinary(),
|
|
||||||
);
|
|
||||||
} catch (e, st) {
|
|
||||||
debugPrint(
|
|
||||||
'❌ [VoiceProvider] Failed serving packet for $sessionId: $e\n$st',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Playback ─────────────────────────────────────────────────────────────
|
// ── Playback ─────────────────────────────────────────────────────────────
|
||||||
@@ -208,11 +304,15 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final pcm = await _codec.decodePackets(session.packets, session.mode);
|
final decodedPcm = await _codec.decodePackets(
|
||||||
|
session.packets,
|
||||||
|
session.mode,
|
||||||
|
);
|
||||||
|
final pcm = _preparePlaybackPcm(decodedPcm, session.mode);
|
||||||
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
debugPrint('🎙️ [VoiceProvider] decoded ${pcm.length} PCM samples');
|
||||||
_playingSessionId = sessionId;
|
_playingSessionId = sessionId;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
await _player.play(pcm);
|
await _player.play(pcm, sampleRateHz: session.mode.sampleRateHz);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
debugPrint('❌ [VoiceProvider] Playback error: $e\n$st');
|
||||||
if (_playingSessionId == sessionId) {
|
if (_playingSessionId == sessionId) {
|
||||||
@@ -228,19 +328,27 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Int16List _preparePlaybackPcm(Int16List pcm, VoicePacketMode mode) {
|
||||||
|
return pcm;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clearStoredVoiceData() async {
|
Future<void> clearStoredVoiceData() async {
|
||||||
_sessions.clear();
|
await _resetInMemoryState();
|
||||||
_outgoingSessions.clear();
|
|
||||||
_playingSessionId = null;
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_voiceSessionsStorageKey);
|
await prefs.remove(_storageKey());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e');
|
debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> reloadProfileScopedState() async {
|
||||||
|
await _resetInMemoryState();
|
||||||
|
await _restorePersistedVoiceData();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _persistVoiceData() async {
|
Future<void> _persistVoiceData() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
@@ -264,7 +372,7 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
};
|
};
|
||||||
await prefs.setString(_voiceSessionsStorageKey, jsonEncode(payload));
|
await prefs.setString(_storageKey(), jsonEncode(payload));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e');
|
debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e');
|
||||||
}
|
}
|
||||||
@@ -273,7 +381,7 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
Future<void> _restorePersistedVoiceData() async {
|
Future<void> _restorePersistedVoiceData() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final raw = prefs.getString(_voiceSessionsStorageKey);
|
final raw = prefs.getString(_storageKey());
|
||||||
if (raw == null || raw.isEmpty) return;
|
if (raw == null || raw.isEmpty) return;
|
||||||
|
|
||||||
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
final parsed = jsonDecode(raw) as Map<String, dynamic>;
|
||||||
@@ -336,6 +444,16 @@ class VoiceProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _resetInMemoryState() async {
|
||||||
|
if (_playingSessionId != null || _player.isPlaying) {
|
||||||
|
await _player.stop();
|
||||||
|
}
|
||||||
|
_sessions.clear();
|
||||||
|
_outgoingSessions.clear();
|
||||||
|
_ignoredIncomingSessions.clear();
|
||||||
|
_playingSessionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_playerEventsSub.cancel();
|
_playerEventsSub.cancel();
|
||||||
|
|||||||
394
lib/screens/add_contact_screen.dart
Normal file
394
lib/screens/add_contact_screen.dart
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class AddContactScreen extends StatefulWidget {
|
||||||
|
final String? initialAdvert;
|
||||||
|
|
||||||
|
const AddContactScreen({super.key, this.initialAdvert});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddContactScreen> createState() => _AddContactScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddContactScreenState extends State<AddContactScreen> {
|
||||||
|
final TextEditingController _advertController = TextEditingController();
|
||||||
|
bool _isImporting = false;
|
||||||
|
bool _importSucceeded = false;
|
||||||
|
String? _validationError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.initialAdvert != null &&
|
||||||
|
widget.initialAdvert!.trim().isNotEmpty) {
|
||||||
|
_advertController.text = widget.initialAdvert!.trim();
|
||||||
|
} else {
|
||||||
|
_loadClipboardIfPresent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_advertController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadClipboardIfPresent() async {
|
||||||
|
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
final text = clipboardData?.text?.trim();
|
||||||
|
if (!mounted || text == null || text.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_normalizeAdvertText(text) == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_advertController.text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _normalizeAdvertText(String value) {
|
||||||
|
final trimmed = value.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = trimmed;
|
||||||
|
if (normalized.startsWith('meshcore://')) {
|
||||||
|
normalized = normalized.substring('meshcore://'.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized = normalized.replaceAll(RegExp(r'\s+'), '');
|
||||||
|
if (normalized.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final isHex = RegExp(r'^[0-9a-fA-F]+$').hasMatch(normalized);
|
||||||
|
if (!isHex || normalized.length.isOdd) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
Uint8List _hexToBytes(String hex) {
|
||||||
|
final bytes = <int>[];
|
||||||
|
for (var i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes.add(int.parse(hex.substring(i, i + 2), radix: 16));
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pasteFromClipboard() async {
|
||||||
|
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
final text = clipboardData?.text;
|
||||||
|
if (text == null || text.trim().isEmpty) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty)),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_advertController.text = text.trim();
|
||||||
|
_importSucceeded = false;
|
||||||
|
_validationError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _importContact() async {
|
||||||
|
final normalized = _normalizeAdvertText(_advertController.text);
|
||||||
|
if (normalized == null) {
|
||||||
|
setState(() {
|
||||||
|
_validationError =
|
||||||
|
'Enter a valid meshcore:// advert or raw hexadecimal contact advert.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final advertBytes = _hexToBytes(normalized);
|
||||||
|
if (advertBytes.length < 98) {
|
||||||
|
setState(() {
|
||||||
|
_validationError =
|
||||||
|
'Advert is too short. Expected exported contact data.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isImporting = true;
|
||||||
|
_importSucceeded = false;
|
||||||
|
_validationError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
await connectionProvider.importContactAdvert(advertBytes);
|
||||||
|
final importError = connectionProvider.error;
|
||||||
|
|
||||||
|
if (importError == null) {
|
||||||
|
await connectionProvider.getContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isImporting = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (importError != null) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(importError)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(AppLocalizations.of(context)!.contactImported)),
|
||||||
|
);
|
||||||
|
setState(() {
|
||||||
|
_importSucceeded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final normalized = _normalizeAdvertText(_advertController.text);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text(AppLocalizations.of(context)!.addContact)),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.person_add_alt_1_outlined,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Import a shared contact advert',
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
'Paste a `meshcore://...` link or raw hexadecimal advert. The app will validate it and import the contact into the connected device.',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_ImportHintChip(
|
||||||
|
icon: Icons.link_outlined,
|
||||||
|
label: AppLocalizations.of(context)!.acceptsShareLinks,
|
||||||
|
),
|
||||||
|
_ImportHintChip(
|
||||||
|
icon: Icons.code_outlined,
|
||||||
|
label: AppLocalizations.of(context)!.supportsRawHex,
|
||||||
|
),
|
||||||
|
_ImportHintChip(
|
||||||
|
icon: Icons.content_paste_go_outlined,
|
||||||
|
label: AppLocalizations.of(context)!.clipboardfriendly,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.outlineVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Advert payload',
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'You can paste the full share link or only the hex payload.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _advertController,
|
||||||
|
minLines: 5,
|
||||||
|
maxLines: 9,
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_validationError != null || _importSucceeded) {
|
||||||
|
setState(() {
|
||||||
|
_importSucceeded = false;
|
||||||
|
_validationError = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Contact advert',
|
||||||
|
hintText: 'Paste a share link or hex advert',
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
errorText: _validationError,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
normalized == null
|
||||||
|
? 'No valid advert detected yet'
|
||||||
|
: 'Advert size: ${normalized.length ~/ 2} bytes',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _isImporting ? null : _pasteFromClipboard,
|
||||||
|
icon: Icon(Icons.content_paste_go_outlined),
|
||||||
|
label: Text(AppLocalizations.of(context)!.paste),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: (_isImporting || _importSucceeded)
|
||||||
|
? null
|
||||||
|
: _importContact,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
icon: _isImporting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: _importSucceeded
|
||||||
|
? const Icon(Icons.check_circle_outline)
|
||||||
|
: const Icon(Icons.person_add_alt_1_outlined),
|
||||||
|
label: Text(
|
||||||
|
_isImporting
|
||||||
|
? 'Importing...'
|
||||||
|
: _importSucceeded
|
||||||
|
? 'Imported'
|
||||||
|
: 'Add Contact',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (_importSucceeded)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: colorScheme.primary.withValues(alpha: 0.18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 18,
|
||||||
|
color: colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Contact imported. Paste another advert or edit the current one to import again.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ImportHintChip extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _ImportHintChip({required this.icon, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(label, style: theme.textTheme.labelMedium),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1047
lib/screens/discovery_screen.dart
Normal file
1047
lib/screens/discovery_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1246
lib/screens/live_traffic_screen.dart
Normal file
1246
lib/screens/live_traffic_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
842
lib/screens/offline_map_screen.dart
Normal file
842
lib/screens/offline_map_screen.dart
Normal file
@@ -0,0 +1,842 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/map_layer.dart';
|
||||||
|
import '../providers/offline_tiles_provider.dart';
|
||||||
|
import '../widgets/map/polygon_draw_handler.dart';
|
||||||
|
|
||||||
|
/// Screen for downloading offline map tiles.
|
||||||
|
///
|
||||||
|
/// Allows drawing polygons/rectangles to select areas, choosing zoom levels,
|
||||||
|
/// and downloading tiles with real-time progress visualization.
|
||||||
|
class OfflineMapScreen extends StatefulWidget {
|
||||||
|
const OfflineMapScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OfflineMapScreen> createState() => _OfflineMapScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfflineMapScreenState extends State<OfflineMapScreen> {
|
||||||
|
final MapController _mapController = MapController();
|
||||||
|
late OfflineTilesProvider _provider;
|
||||||
|
double _currentZoom = 8;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_provider = context.read<OfflineTilesProvider>();
|
||||||
|
_provider.refreshCacheSize();
|
||||||
|
_provider.refreshLocalStyles();
|
||||||
|
_provider.startPeerDiscovery();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_provider.stopPeerDiscovery();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Offline Maps'),
|
||||||
|
actions: [
|
||||||
|
Consumer<OfflineTilesProvider>(
|
||||||
|
builder: (context, provider, _) {
|
||||||
|
return IconButton(
|
||||||
|
icon: Badge(
|
||||||
|
isLabelVisible: provider.discoveredPeers.isNotEmpty,
|
||||||
|
label: Text('${provider.discoveredPeers.length}'),
|
||||||
|
child: Icon(
|
||||||
|
provider.isServerRunning
|
||||||
|
? Icons.wifi_tethering
|
||||||
|
: Icons.wifi_tethering_off,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tooltip: provider.isServerRunning
|
||||||
|
? 'Sharing tiles'
|
||||||
|
: 'Tile sharing off',
|
||||||
|
onPressed: () => _showSharingSheet(context, provider),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Consumer<OfflineTilesProvider>(
|
||||||
|
builder: (context, provider, _) {
|
||||||
|
return PopupMenuButton<MapLayer>(
|
||||||
|
icon: const Icon(Icons.layers),
|
||||||
|
tooltip: 'Map Style',
|
||||||
|
onSelected: (layer) => provider.setSelectedLayer(layer),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
for (final layer in MapLayer.allLayers)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: layer,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (layer.type == provider.selectedLayer.type)
|
||||||
|
const Icon(Icons.check, size: 18)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(layer.name),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Consumer<OfflineTilesProvider>(
|
||||||
|
builder: (context, provider, _) {
|
||||||
|
return FlutterMap(
|
||||||
|
mapController: _mapController,
|
||||||
|
options: MapOptions(
|
||||||
|
initialCenter: const LatLng(46.05, 14.5), // Slovenia
|
||||||
|
initialZoom: 8,
|
||||||
|
onPositionChanged: (camera, hasGesture) {
|
||||||
|
if (camera.zoom != _currentZoom) {
|
||||||
|
setState(() => _currentZoom = camera.zoom);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTap: (tapPosition, point) {
|
||||||
|
if (provider.drawingMode != DrawingMode.none) {
|
||||||
|
provider.addVertex(point);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TileLayer(
|
||||||
|
urlTemplate: provider.selectedLayer.urlTemplate,
|
||||||
|
userAgentPackageName: 'com.meshcore.sar',
|
||||||
|
maxZoom: provider.selectedLayer.maxZoom,
|
||||||
|
),
|
||||||
|
CoverageLayer(currentZoom: _currentZoom),
|
||||||
|
const PolygonDrawLayer(),
|
||||||
|
const DownloadProgressLayer(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const DrawingToolbar(),
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
top: 16,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.15),
|
||||||
|
blurRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Z ${_currentZoom.toStringAsFixed(1)}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildBottomPanel(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomPanel() {
|
||||||
|
return Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Consumer<OfflineTilesProvider>(
|
||||||
|
builder: (context, provider, _) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.15),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Saved region presets dropdown
|
||||||
|
if (!provider.isDownloading &&
|
||||||
|
provider.localStyles
|
||||||
|
.any((s) => s.region != null)) ...[
|
||||||
|
DropdownButtonFormField<StyleInfo>(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
prefixIcon: Icon(Icons.bookmark, size: 20),
|
||||||
|
labelText: 'Saved regions',
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 12, vertical: 8),
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
isExpanded: true,
|
||||||
|
hint: const Text('Load a saved region'),
|
||||||
|
items: provider.localStyles
|
||||||
|
.where((s) => s.region != null)
|
||||||
|
.map((style) => DropdownMenuItem(
|
||||||
|
value: style,
|
||||||
|
child: Text(
|
||||||
|
'${style.displayName} '
|
||||||
|
'(z${style.region!.minZoom}-${style.region!.maxZoom}, '
|
||||||
|
'${_formatNumber(style.tileCount)} tiles)',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (style) {
|
||||||
|
if (style != null) {
|
||||||
|
provider.loadPreset(style);
|
||||||
|
_fitMapToPolygons(provider);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Zoom range
|
||||||
|
if (!provider.isDownloading) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _ZoomSelector(
|
||||||
|
label: 'Min Zoom',
|
||||||
|
value: provider.minZoom,
|
||||||
|
onChanged: provider.setMinZoom,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: _ZoomSelector(
|
||||||
|
label: 'Max Zoom',
|
||||||
|
value: provider.maxZoom,
|
||||||
|
onChanged: provider.setMaxZoom,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// Tile estimate
|
||||||
|
Text(
|
||||||
|
provider.hasPolygons
|
||||||
|
? '~${_formatNumber(provider.estimatedTileCount)} tiles'
|
||||||
|
: 'Draw an area to download',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Cache: ${_formatBytes(provider.cacheSizeBytes)}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Progress
|
||||||
|
if (provider.isDownloading) ...[
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: provider.progress.percent,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${(provider.progress.percent * 100).toStringAsFixed(1)}% — '
|
||||||
|
'Downloaded: ${provider.progress.downloaded}, '
|
||||||
|
'Cached: ${provider.progress.skipped}, '
|
||||||
|
'Failed: ${provider.progress.failed} / '
|
||||||
|
'${provider.progress.total}',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Download complete summary
|
||||||
|
if (!provider.isDownloading &&
|
||||||
|
provider.progress.isComplete) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Done! ${provider.progress.downloaded} downloaded, '
|
||||||
|
'${provider.progress.skipped} cached, '
|
||||||
|
'${provider.progress.failed} failed',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (!provider.isDownloading) ...[
|
||||||
|
Expanded(
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: provider.hasPolygons
|
||||||
|
? () => provider.startDownload()
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: const Text('Download'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (provider.tileOverlays.isNotEmpty) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: provider.clearOverlays,
|
||||||
|
icon: const Icon(Icons.layers_clear),
|
||||||
|
tooltip: 'Clear overlay',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (provider.cacheSizeBytes > 0) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => _confirmClearCache(provider),
|
||||||
|
icon: const Icon(Icons.delete_forever),
|
||||||
|
tooltip: 'Clear cache',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
if (provider.isDownloading) ...[
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => provider.cancelDownload(),
|
||||||
|
icon: const Icon(Icons.cancel),
|
||||||
|
label: const Text('Cancel'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSharingSheet(BuildContext context, OfflineTilesProvider provider) {
|
||||||
|
// Refresh catalogs and local styles when opening
|
||||||
|
provider.refreshPeerCatalogs();
|
||||||
|
provider.refreshLocalStyles();
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.65,
|
||||||
|
minChildSize: 0.3,
|
||||||
|
maxChildSize: 0.9,
|
||||||
|
expand: false,
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
return Consumer<OfflineTilesProvider>(
|
||||||
|
builder: (context, provider, _) {
|
||||||
|
return ListView(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[400],
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Tile Sharing',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Server toggle
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Share my tiles'),
|
||||||
|
subtitle: Text(
|
||||||
|
provider.isServerRunning
|
||||||
|
? 'Other devices can fetch tiles from this device'
|
||||||
|
: 'Start serving cached tiles to nearby devices',
|
||||||
|
),
|
||||||
|
secondary: Icon(
|
||||||
|
provider.isServerRunning
|
||||||
|
? Icons.wifi_tethering
|
||||||
|
: Icons.wifi_tethering_off,
|
||||||
|
),
|
||||||
|
value: provider.isServerRunning,
|
||||||
|
onChanged: (_) => provider.toggleServer(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// My cached maps
|
||||||
|
if (provider.localStyles.isNotEmpty) ...[
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
'My Cached Maps',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...provider.localStyles.map((style) {
|
||||||
|
final isShowing =
|
||||||
|
provider.coverageStyle?.hash == style.hash;
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: const Icon(Icons.map, size: 20),
|
||||||
|
title: Text(style.displayName),
|
||||||
|
subtitle: Text(
|
||||||
|
'${_formatNumber(style.tileCount)} tiles, '
|
||||||
|
'${_formatBytes(style.sizeBytes)}',
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
isShowing
|
||||||
|
? Icons.visibility
|
||||||
|
: Icons.visibility_off,
|
||||||
|
size: 20,
|
||||||
|
color: isShowing
|
||||||
|
? Colors.blue
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
tooltip: isShowing
|
||||||
|
? 'Hide on map'
|
||||||
|
: 'Show on map',
|
||||||
|
onPressed: () {
|
||||||
|
provider.showCoverage(style);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline,
|
||||||
|
size: 20),
|
||||||
|
tooltip: 'Delete',
|
||||||
|
onPressed: () => _confirmDeleteStyle(
|
||||||
|
context, provider, style),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Peers section
|
||||||
|
const Divider(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Nearby Devices',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (provider.isFetchingCatalogs)
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child:
|
||||||
|
CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, size: 20),
|
||||||
|
tooltip: 'Refresh',
|
||||||
|
onPressed: () =>
|
||||||
|
provider.refreshPeerCatalogs(),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add, size: 20),
|
||||||
|
tooltip: 'Add peer manually',
|
||||||
|
onPressed: () =>
|
||||||
|
_showAddPeerDialog(context, provider),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (provider.discoveredPeers.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Text(
|
||||||
|
'No peers found on the local network.\n'
|
||||||
|
'Make sure other devices have tile sharing enabled.',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Peer catalogs — show each peer and their available styles
|
||||||
|
...provider.peerCatalogs.map((catalog) => _buildPeerCard(
|
||||||
|
context, provider, catalog)),
|
||||||
|
|
||||||
|
// Peers without catalogs yet (just discovered, not queried)
|
||||||
|
...provider.discoveredPeers
|
||||||
|
.where((peer) => !provider.peerCatalogs
|
||||||
|
.any((c) => c.peer == peer))
|
||||||
|
.map((peer) => ListTile(
|
||||||
|
leading: const Icon(Icons.devices),
|
||||||
|
title: Text(peer.ipAddress),
|
||||||
|
subtitle: const Text('Fetching catalog...'),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.remove_circle_outline,
|
||||||
|
size: 20),
|
||||||
|
onPressed: () =>
|
||||||
|
provider.removePeer(peer),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
|
||||||
|
// Sync progress
|
||||||
|
if (provider.isSyncing || provider.syncStatus.isNotEmpty) ...[
|
||||||
|
const Divider(),
|
||||||
|
if (provider.isSyncing)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: provider.syncProgress > 0
|
||||||
|
? provider.syncProgress
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
provider.syncStatus,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (provider.isSyncing)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => provider.cancelSync(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPeerCard(
|
||||||
|
BuildContext context,
|
||||||
|
OfflineTilesProvider provider,
|
||||||
|
PeerCatalog catalog,
|
||||||
|
) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.devices, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
catalog.peer.ipAddress,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline, size: 18),
|
||||||
|
onPressed: () => provider.removePeer(catalog.peer),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (catalog.styles.isEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
'No cached tiles on this device',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurface
|
||||||
|
.withValues(alpha: 0.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...catalog.styles.map((style) {
|
||||||
|
// Check if we already have this style locally
|
||||||
|
final localMatch = provider.localStyles
|
||||||
|
.where((s) => s.hash == style.hash);
|
||||||
|
final localCount =
|
||||||
|
localMatch.isNotEmpty ? localMatch.first.tileCount : 0;
|
||||||
|
final missingTiles = style.tileCount - localCount;
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: const Icon(Icons.map_outlined, size: 20),
|
||||||
|
title: Text(style.displayName),
|
||||||
|
subtitle: Text(
|
||||||
|
'${_formatNumber(style.tileCount)} tiles, '
|
||||||
|
'${_formatBytes(style.sizeBytes)}'
|
||||||
|
'${localCount > 0 ? ' (you have ${_formatNumberInline(localCount)})' : ''}',
|
||||||
|
),
|
||||||
|
trailing: missingTiles > 0
|
||||||
|
? TextButton.icon(
|
||||||
|
onPressed: provider.isSyncing
|
||||||
|
? null
|
||||||
|
: () => provider.syncStyleFromPeers(style),
|
||||||
|
icon: const Icon(Icons.download, size: 16),
|
||||||
|
label: Text(
|
||||||
|
missingTiles == style.tileCount
|
||||||
|
? 'Get all'
|
||||||
|
: '+${_formatNumber(missingTiles)}',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.check_circle,
|
||||||
|
color: Colors.green, size: 20),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatNumberInline(int n) {
|
||||||
|
if (n < 1000) return '$n';
|
||||||
|
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
|
||||||
|
return '${(n / 1000000).toStringAsFixed(1)}M';
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fitMapToPolygons(OfflineTilesProvider provider) {
|
||||||
|
if (provider.polygons.isEmpty) return;
|
||||||
|
|
||||||
|
var minLat = 90.0, maxLat = -90.0;
|
||||||
|
var minLng = 180.0, maxLng = -180.0;
|
||||||
|
for (final poly in provider.polygons) {
|
||||||
|
for (final p in poly) {
|
||||||
|
if (p.latitude < minLat) minLat = p.latitude;
|
||||||
|
if (p.latitude > maxLat) maxLat = p.latitude;
|
||||||
|
if (p.longitude < minLng) minLng = p.longitude;
|
||||||
|
if (p.longitude > maxLng) maxLng = p.longitude;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_mapController.fitCamera(
|
||||||
|
CameraFit.bounds(
|
||||||
|
bounds: LatLngBounds(
|
||||||
|
LatLng(minLat, minLng),
|
||||||
|
LatLng(maxLat, maxLng),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(50),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAddPeerDialog(
|
||||||
|
BuildContext context, OfflineTilesProvider provider) {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Add Peer'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'IP Address',
|
||||||
|
hintText: '192.168.1.100',
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
autofocus: true,
|
||||||
|
onSubmitted: (value) {
|
||||||
|
if (value.trim().isNotEmpty) {
|
||||||
|
provider.addManualPeer(value.trim());
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
final ip = controller.text.trim();
|
||||||
|
if (ip.isNotEmpty) {
|
||||||
|
provider.addManualPeer(ip);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Add'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmDeleteStyle(
|
||||||
|
BuildContext context,
|
||||||
|
OfflineTilesProvider provider,
|
||||||
|
StyleInfo style,
|
||||||
|
) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text('Delete ${style.displayName}?'),
|
||||||
|
content: Text(
|
||||||
|
'${_formatNumber(style.tileCount)} tiles, '
|
||||||
|
'${_formatBytes(style.sizeBytes)} will be deleted.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
provider.deleteStyle(style);
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmClearCache(OfflineTilesProvider provider) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Clear offline cache?'),
|
||||||
|
content: Text(
|
||||||
|
'This will delete ${_formatBytes(provider.cacheSizeBytes)} of cached tiles.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
provider.clearCache();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
const Text('Delete', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatBytes(int bytes) {
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatNumber(int n) {
|
||||||
|
if (n < 1000) return '$n';
|
||||||
|
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
|
||||||
|
return '${(n / 1000000).toStringAsFixed(1)}M';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ZoomSelector extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final int value;
|
||||||
|
final ValueChanged<int> onChanged;
|
||||||
|
|
||||||
|
const _ZoomSelector({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Slider(
|
||||||
|
value: value.toDouble(),
|
||||||
|
min: 0,
|
||||||
|
max: 19,
|
||||||
|
divisions: 19,
|
||||||
|
label: '$value',
|
||||||
|
onChanged: (v) => onChanged(v.round()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
child: Text(
|
||||||
|
'$value',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -55,7 +61,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('No logs to export')));
|
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.noLogsToExport)));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -102,7 +108,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(const SnackBar(content: Text('No logs to export')));
|
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.noLogsToExport)));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -149,8 +155,8 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
void _copyToClipboard(BuildContext context, BlePacketLog log) {
|
void _copyToClipboard(BuildContext context, BlePacketLog log) {
|
||||||
Clipboard.setData(ClipboardData(text: log.hexData));
|
Clipboard.setData(ClipboardData(text: log.hexData));
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
SnackBar(
|
||||||
content: Text('Hex data copied to clipboard'),
|
content: Text(AppLocalizations.of(context)!.hexDataCopiedToClipboard),
|
||||||
duration: Duration(seconds: 1),
|
duration: Duration(seconds: 1),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -178,7 +184,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
setState(() {});
|
setState(() {});
|
||||||
if (parentContext.mounted) {
|
if (parentContext.mounted) {
|
||||||
ScaffoldMessenger.of(parentContext).showSnackBar(
|
ScaffoldMessenger.of(parentContext).showSnackBar(
|
||||||
const SnackBar(content: Text('Packet logs cleared')),
|
SnackBar(content: Text(AppLocalizations.of(context)!.packetLogsCleared)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -199,7 +205,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
title: Column(
|
title: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text('BLE Packet Logs'),
|
Text(AppLocalizations.of(context)!.blePacketLogs),
|
||||||
Text(
|
Text(
|
||||||
'${logs.length} packets',
|
'${logs.length} packets',
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
@@ -310,23 +316,23 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
icon: const Icon(Icons.share),
|
icon: const Icon(Icons.share),
|
||||||
tooltip: 'Export logs',
|
tooltip: 'Export logs',
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'csv',
|
value: 'csv',
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.table_chart),
|
const Icon(Icons.table_chart),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('Export as CSV'),
|
Text(AppLocalizations.of(context)!.exportAsCsv),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'txt',
|
value: 'txt',
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.text_snippet),
|
const Icon(Icons.text_snippet),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text('Export as Text'),
|
Text(AppLocalizations.of(context)!.exportAsText),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -405,8 +411,8 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
|
|||||||
_filterDirection = null;
|
_filterDirection = null;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.clear_all),
|
icon: Icon(Icons.clear_all),
|
||||||
label: const Text('Clear filters'),
|
label: Text(AppLocalizations.of(context)!.clearFilters),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -513,24 +519,24 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_FactCard(
|
_FactCard(
|
||||||
icon: isRx ? Icons.call_received : Icons.call_made,
|
icon: isRx ? Icons.call_received : Icons.call_made,
|
||||||
label: 'Direction',
|
label: AppLocalizations.of(context)!.direction,
|
||||||
value: isRx ? 'RX' : 'TX',
|
value: isRx ? 'RX' : 'TX',
|
||||||
accent: directionColor,
|
accent: directionColor,
|
||||||
),
|
),
|
||||||
_FactCard(
|
_FactCard(
|
||||||
icon: Icons.data_object,
|
icon: Icons.data_object,
|
||||||
label: 'Size',
|
label: AppLocalizations.of(context)!.size,
|
||||||
value: '${log.rawData.length} bytes',
|
value: '${log.rawData.length} bytes',
|
||||||
),
|
),
|
||||||
_FactCard(
|
_FactCard(
|
||||||
icon: Icons.schedule,
|
icon: Icons.schedule,
|
||||||
label: 'Captured',
|
label: AppLocalizations.of(context)!.captured,
|
||||||
value: _formatTimestamp(log.timestamp),
|
value: _formatTimestamp(log.timestamp),
|
||||||
),
|
),
|
||||||
if (log.responseCode != null)
|
if (log.responseCode != null)
|
||||||
_FactCard(
|
_FactCard(
|
||||||
icon: Icons.sell,
|
icon: Icons.sell,
|
||||||
label: 'Opcode',
|
label: AppLocalizations.of(context)!.opcode,
|
||||||
value: log.opcodeName,
|
value: log.opcodeName,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -559,7 +565,7 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
if (rxInfo?.rssiDbm != null)
|
if (rxInfo?.rssiDbm != null)
|
||||||
_SignalMeter(
|
_SignalMeter(
|
||||||
label: 'RSSI',
|
label: AppLocalizations.of(context)!.rssi,
|
||||||
valueLabel: '${rxInfo!.rssiDbm} dBm',
|
valueLabel: '${rxInfo!.rssiDbm} dBm',
|
||||||
normalized: _normalizeRssi(
|
normalized: _normalizeRssi(
|
||||||
rxInfo.rssiDbm!.toDouble(),
|
rxInfo.rssiDbm!.toDouble(),
|
||||||
@@ -567,9 +573,9 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
color: _rssiColor(rxInfo.rssiDbm!.toDouble()),
|
color: _rssiColor(rxInfo.rssiDbm!.toDouble()),
|
||||||
),
|
),
|
||||||
if (rxInfo?.snrDb != null) ...[
|
if (rxInfo?.snrDb != null) ...[
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
_SignalMeter(
|
_SignalMeter(
|
||||||
label: 'SNR',
|
label: AppLocalizations.of(context)!.snr,
|
||||||
valueLabel:
|
valueLabel:
|
||||||
'${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
|
'${rxInfo!.snrDb!.toStringAsFixed(1)} dB',
|
||||||
normalized: _normalizeSnr(rxInfo.snrDb!),
|
normalized: _normalizeSnr(rxInfo.snrDb!),
|
||||||
@@ -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,242 @@ class _PacketLogCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DecodedRouteSection extends StatelessWidget {
|
||||||
|
final BlePacketLog log;
|
||||||
|
|
||||||
|
const _DecodedRouteSection({required this.log});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contacts = context.watch<ContactsProvider>().contacts;
|
||||||
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
|
final ownPublicKey = connectionProvider.deviceInfo.publicKey;
|
||||||
|
final ownName =
|
||||||
|
connectionProvider.deviceInfo.selfName ??
|
||||||
|
connectionProvider.deviceInfo.displayName;
|
||||||
|
|
||||||
|
return FutureBuilder<List<dynamic>>(
|
||||||
|
future: Future.wait<dynamic>([
|
||||||
|
RouteHashPreferences.getHashSize(),
|
||||||
|
MeshMapNodesService.loadCachedNodes(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final decodedRoute = LogRxRouteDecoder.decode(
|
||||||
|
log.rawData,
|
||||||
|
preferredHashSize: snapshot.data?.first as int?,
|
||||||
|
);
|
||||||
|
if (decodedRoute == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final cachedNodes = snapshot.hasData
|
||||||
|
? snapshot.data![1] as List<MeshMapNode>
|
||||||
|
: const <MeshMapNode>[];
|
||||||
|
final resolvedPath = decodedRoute.hopHashes
|
||||||
|
.map(
|
||||||
|
(hashHex) => _resolveHashWithFallback(
|
||||||
|
hashHex,
|
||||||
|
contacts: contacts,
|
||||||
|
cachedNodes: cachedNodes,
|
||||||
|
ownPublicKey: ownPublicKey,
|
||||||
|
ownName: ownName,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
return _RouteSection(route: decodedRoute, path: resolvedPath);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ResolvedNodeHash _resolveHashWithFallback(
|
||||||
|
String hashHex, {
|
||||||
|
required List<Contact> contacts,
|
||||||
|
required List<MeshMapNode> cachedNodes,
|
||||||
|
required Uint8List? ownPublicKey,
|
||||||
|
required String? ownName,
|
||||||
|
}) {
|
||||||
|
final localResolved = LogRxRouteDecoder.resolveHash(
|
||||||
|
hashHex,
|
||||||
|
contacts: contacts,
|
||||||
|
ownPublicKey: ownPublicKey,
|
||||||
|
ownName: ownName,
|
||||||
|
);
|
||||||
|
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
|
||||||
|
return localResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedHashHex = hashHex.toLowerCase();
|
||||||
|
final matches = cachedNodes
|
||||||
|
.where((node) => node.publicKey.startsWith(normalizedHashHex))
|
||||||
|
.toList();
|
||||||
|
if (matches.isEmpty) {
|
||||||
|
return localResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||||
|
return ResolvedNodeHash(
|
||||||
|
hashHex: normalizedHashHex,
|
||||||
|
label: matches.first.name,
|
||||||
|
isOwnNode: false,
|
||||||
|
isUniqueMatch: matches.length == 1,
|
||||||
|
matchCount: matches.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RouteSection extends StatelessWidget {
|
||||||
|
final DecodedLogRxRoute route;
|
||||||
|
final List<ResolvedNodeHash> path;
|
||||||
|
|
||||||
|
const _RouteSection({required this.route, required this.path});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.alt_route, size: 16),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Mesh Route',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: [
|
||||||
|
_FactCard(
|
||||||
|
icon: Icons.route,
|
||||||
|
label: AppLocalizations.of(context)!.payload,
|
||||||
|
value: _payloadTypeLabel(route.payloadType),
|
||||||
|
),
|
||||||
|
_FactCard(
|
||||||
|
icon: Icons.hub,
|
||||||
|
label: AppLocalizations.of(context)!.hops,
|
||||||
|
value: '${route.hopCount}',
|
||||||
|
),
|
||||||
|
_FactCard(
|
||||||
|
icon: Icons.tag,
|
||||||
|
label: AppLocalizations.of(context)!.hashSize,
|
||||||
|
value:
|
||||||
|
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (path.isEmpty)
|
||||||
|
Text(
|
||||||
|
'Direct packet, no hop path attached.',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < path.length; i++) ...[
|
||||||
|
_RouteHopChip(index: i + 1, node: path[i]),
|
||||||
|
if (i < path.length - 1)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
child: Icon(Icons.arrow_right_alt, size: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _payloadTypeLabel(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'REQ';
|
||||||
|
case 0x01:
|
||||||
|
return 'RESP';
|
||||||
|
case 0x02:
|
||||||
|
return 'TXT';
|
||||||
|
case 0x03:
|
||||||
|
return 'ACK';
|
||||||
|
case 0x04:
|
||||||
|
return 'ADVERT';
|
||||||
|
case 0x05:
|
||||||
|
return 'GRP_TXT';
|
||||||
|
case 0x06:
|
||||||
|
return 'GRP_DATA';
|
||||||
|
case 0x07:
|
||||||
|
return 'ANON_REQ';
|
||||||
|
case 0x08:
|
||||||
|
return 'PATH';
|
||||||
|
case 0x09:
|
||||||
|
return 'TRACE';
|
||||||
|
case 0x0A:
|
||||||
|
return 'MULTIPART';
|
||||||
|
case 0x0B:
|
||||||
|
return 'CONTROL';
|
||||||
|
default:
|
||||||
|
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _nodeLabel(ResolvedNodeHash node) {
|
||||||
|
if (node.isOwnNode) {
|
||||||
|
return '${node.label} (${node.hexLabel})';
|
||||||
|
}
|
||||||
|
if (node.matchCount == 0) {
|
||||||
|
return node.hexLabel;
|
||||||
|
}
|
||||||
|
if (node.isUniqueMatch) {
|
||||||
|
return '${node.label} (${node.hexLabel})';
|
||||||
|
}
|
||||||
|
return '${node.label} (${node.hexLabel}, ${node.matchCount} matches)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RouteHopChip extends StatelessWidget {
|
||||||
|
final int index;
|
||||||
|
final ResolvedNodeHash node;
|
||||||
|
|
||||||
|
const _RouteHopChip({required this.index, required this.node});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = node.isOwnNode
|
||||||
|
? Colors.blue
|
||||||
|
: node.isUniqueMatch
|
||||||
|
? Colors.green
|
||||||
|
: Colors.orange;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.35)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$index. ${_RouteSection._nodeLabel(node)}',
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _FactCard extends StatelessWidget {
|
class _FactCard extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
|||||||
251
lib/screens/profiles_screen.dart
Normal file
251
lib/screens/profiles_screen.dart
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../models/config_profile.dart';
|
||||||
|
import '../services/profile_manager.dart';
|
||||||
|
import '../services/profile_workspace_coordinator.dart';
|
||||||
|
import '../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class ProfilesScreen extends StatelessWidget {
|
||||||
|
const ProfilesScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<ProfileManager>(
|
||||||
|
builder: (context, profileManager, child) {
|
||||||
|
final profiles = profileManager.visibleProfiles;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(AppLocalizations.of(context)!.profiles),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await context
|
||||||
|
.read<ProfileWorkspaceCoordinator>()
|
||||||
|
.importProfileFromFile();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.file_open),
|
||||||
|
tooltip: 'Import profile',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: () => _createProfile(context),
|
||||||
|
icon: Icon(Icons.add),
|
||||||
|
label: Text(AppLocalizations.of(context)!.newProfile),
|
||||||
|
),
|
||||||
|
body: profiles.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(AppLocalizations.of(context)!.enableProfilesToStartManagingThem),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: profiles.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final profile = profiles[index];
|
||||||
|
final isActive =
|
||||||
|
profileManager.activeProfileId == profile.id;
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
profile.name,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isActive)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(AppLocalizations.of(context)!.active),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(_summary(profile)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await context
|
||||||
|
.read<ProfileWorkspaceCoordinator>()
|
||||||
|
.openProfile(profile.id);
|
||||||
|
},
|
||||||
|
child: Text(AppLocalizations.of(context)!.open),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final resolved =
|
||||||
|
profile.id ==
|
||||||
|
ConfigProfile.defaultProfileId
|
||||||
|
? await context
|
||||||
|
.read<
|
||||||
|
ProfileWorkspaceCoordinator
|
||||||
|
>()
|
||||||
|
.snapshotCurrentProfile(
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.name,
|
||||||
|
)
|
||||||
|
: profile;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await context
|
||||||
|
.read<ProfileWorkspaceCoordinator>()
|
||||||
|
.exportProfile(resolved);
|
||||||
|
},
|
||||||
|
child: Text(AppLocalizations.of(context)!.share),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (value) async {
|
||||||
|
switch (value) {
|
||||||
|
case 'duplicate':
|
||||||
|
await context
|
||||||
|
.read<ProfileWorkspaceCoordinator>()
|
||||||
|
.duplicateProfile(profile);
|
||||||
|
break;
|
||||||
|
case 'rename':
|
||||||
|
await _renameProfile(context, profile);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
await context
|
||||||
|
.read<ProfileWorkspaceCoordinator>()
|
||||||
|
.deleteProfile(profile);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'duplicate',
|
||||||
|
child: Text(AppLocalizations.of(context)!.duplicate),
|
||||||
|
),
|
||||||
|
if (!profile.isDefault)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'rename',
|
||||||
|
child: Text(AppLocalizations.of(context)!.rename),
|
||||||
|
),
|
||||||
|
if (!profile.isDefault)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _summary(ConfigProfile profile) {
|
||||||
|
if (profile.isDefault) {
|
||||||
|
return 'Current app state and history.';
|
||||||
|
}
|
||||||
|
|
||||||
|
final sections = <String>[];
|
||||||
|
if (profile.sections.deviceConfig?.isEmpty == false) {
|
||||||
|
sections.add('Device');
|
||||||
|
}
|
||||||
|
if (profile.sections.channels.isNotEmpty) {
|
||||||
|
sections.add('${profile.sections.channels.length} channels');
|
||||||
|
}
|
||||||
|
if (profile.sections.appSettings?.isEmpty == false) {
|
||||||
|
sections.add('App settings');
|
||||||
|
}
|
||||||
|
if (profile.sections.mapWorkspace?.isEmpty == false) {
|
||||||
|
sections.add('Map workspace');
|
||||||
|
}
|
||||||
|
return sections.isEmpty ? 'Empty profile' : sections.join(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createProfile(BuildContext context) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final name = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(AppLocalizations.of(context)!.createProfile),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Profile name'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
|
||||||
|
child: Text(AppLocalizations.of(context)!.create),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (name == null || name.isEmpty || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await context.read<ProfileWorkspaceCoordinator>().createProfileFromCurrent(
|
||||||
|
name: name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _renameProfile(
|
||||||
|
BuildContext context,
|
||||||
|
ConfigProfile profile,
|
||||||
|
) async {
|
||||||
|
final controller = TextEditingController(text: profile.name);
|
||||||
|
final name = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(AppLocalizations.of(context)!.renameProfile),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'Profile name'),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (name == null || name.isEmpty || !context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await context.read<ProfileWorkspaceCoordinator>().renameProfile(
|
||||||
|
profile,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
776
lib/screens/repeaters_map_screen.dart
Normal file
776
lib/screens/repeaters_map_screen.dart
Normal file
@@ -0,0 +1,776 @@
|
|||||||
|
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';
|
||||||
|
import '../l10n/app_localizations.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),
|
||||||
|
),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
_InfoLine(
|
||||||
|
label: AppLocalizations.of(context)!.publicKeyLabel,
|
||||||
|
value: repeater.publicKey,
|
||||||
|
monospace: true,
|
||||||
|
),
|
||||||
|
_InfoLine(
|
||||||
|
label: AppLocalizations.of(context)!.coordinates,
|
||||||
|
value:
|
||||||
|
'${repeater.latitude.toStringAsFixed(5)}, ${repeater.longitude.toStringAsFixed(5)}',
|
||||||
|
),
|
||||||
|
_InfoLine(label: AppLocalizations.of(context)!.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: Icon(Icons.check_circle),
|
||||||
|
label: Text(AppLocalizations.of(context)!.alreadyInContacts),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(AppLocalizations.of(context)!.connectToADeviceBeforeAddingContacts),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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: Text(AppLocalizations.of(context)!.repeatersMap),
|
||||||
|
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: [
|
||||||
|
_LegendRow(
|
||||||
|
color: const Color(0xFF7C3AED),
|
||||||
|
label: AppLocalizations.of(context)!.fromContacts,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_LegendRow(
|
||||||
|
color: const Color(0xFFE8681D),
|
||||||
|
label: AppLocalizations.of(context)!.onlineOnly,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_LegendRow(color: const Color(0xFF1B8F4F), label: AppLocalizations.of(context)!.inBoth),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -240,7 +240,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
|
|||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'import',
|
value: 'import',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: const Icon(Icons.download),
|
leading: Icon(Icons.download),
|
||||||
title: Text(l10n.importFromClipboard),
|
title: Text(l10n.importFromClipboard),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
@@ -248,7 +248,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
|
|||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'export',
|
value: 'export',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: const Icon(Icons.upload),
|
leading: Icon(Icons.upload),
|
||||||
title: Text(l10n.exportToClipboard),
|
title: Text(l10n.exportToClipboard),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
@@ -257,7 +257,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
|
|||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'reset',
|
value: 'reset',
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: const Icon(Icons.restart_alt),
|
leading: Icon(Icons.restart_alt),
|
||||||
title: Text(l10n.resetToDefaults),
|
title: Text(l10n.resetToDefaults),
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
@@ -283,14 +283,14 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
|
|||||||
size: 64,
|
size: 64,
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.3),
|
color: colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
l10n.noTemplates,
|
l10n.noTemplates,
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
l10n.tapAddToCreate,
|
l10n.tapAddToCreate,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
@@ -317,7 +317,7 @@ class _SarTemplateManagementScreenState extends State<SarTemplateManagementScree
|
|||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
onPressed: _addTemplate,
|
onPressed: _addTemplate,
|
||||||
icon: const Icon(Icons.add),
|
icon: Icon(Icons.add),
|
||||||
label: Text(l10n.addTemplate),
|
label: Text(l10n.addTemplate),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
1282
lib/screens/sensors_tab.dart
Normal file
1282
lib/screens/sensors_tab.dart
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package: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,97 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openConnectionDialog() async {
|
||||||
|
final connected = await showConnectionDialogFlow(
|
||||||
|
context,
|
||||||
|
offerPostConnectRepeaterDiscovery: true,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
final connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
if (connected) {
|
||||||
|
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,14 +323,14 @@ 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: [
|
||||||
if (_currentPage > 0)
|
if (_currentPage > 0)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: _previousPage,
|
onPressed: _previousPage,
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: Icon(Icons.arrow_back),
|
||||||
label: Text(l10n.wizardBack),
|
label: Text(l10n.wizardBack),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -96,10 +351,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 +365,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 +387,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 +399,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 +424,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 +449,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,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
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 +679,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 +698,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 +722,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 +873,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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
150
lib/services/app_config_snapshot_service.dart
Normal file
150
lib/services/app_config_snapshot_service.dart
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/config_profile.dart';
|
||||||
|
import '../providers/app_provider.dart';
|
||||||
|
import 'image_preferences.dart';
|
||||||
|
import 'profiles_feature_service.dart';
|
||||||
|
import 'route_hash_preferences.dart';
|
||||||
|
import 'voice_bitrate_preferences.dart';
|
||||||
|
|
||||||
|
class AppConfigSnapshotService {
|
||||||
|
Future<AppSettingsProfileSection> capture(AppProvider appProvider) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final locationTracking = appProvider.locationTrackingService;
|
||||||
|
return AppSettingsProfileSection(
|
||||||
|
mapEnabled: appProvider.isMapEnabled,
|
||||||
|
contactsEnabled: appProvider.isContactsEnabled,
|
||||||
|
sensorsEnabled: appProvider.isSensorsEnabled,
|
||||||
|
voiceSilenceTrimmingEnabled: appProvider.isVoiceSilenceTrimmingEnabled,
|
||||||
|
voiceBandPassFilterEnabled: appProvider.isVoiceBandPassFilterEnabled,
|
||||||
|
voiceCompressorEnabled: appProvider.isVoiceCompressorEnabled,
|
||||||
|
voiceLimiterEnabled: appProvider.isVoiceLimiterEnabled,
|
||||||
|
voiceAutoGainEnabled: appProvider.isVoiceAutoGainEnabled,
|
||||||
|
voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled,
|
||||||
|
voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled,
|
||||||
|
messageFontScale: appProvider.messageFontScale,
|
||||||
|
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
||||||
|
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
|
||||||
|
voiceBitrate: await VoiceBitratePreferences.getBitrate(),
|
||||||
|
routeHashSize: await RouteHashPreferences.getHashSize(),
|
||||||
|
imageMaxSize: await ImagePreferences.getMaxSize(),
|
||||||
|
imageCompression: await ImagePreferences.getCompression(),
|
||||||
|
imageGrayscale: await ImagePreferences.getGrayscale(),
|
||||||
|
imageUltraMode: await ImagePreferences.getUltraMode(),
|
||||||
|
showRxTxIndicators:
|
||||||
|
prefs.getBool(
|
||||||
|
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||||
|
) ??
|
||||||
|
true,
|
||||||
|
fastLocationUpdatesEnabled: locationTracking.fastLocationUpdatesEnabled,
|
||||||
|
fastLocationMovementThresholdMeters:
|
||||||
|
locationTracking.fastLocationMovementThresholdMeters,
|
||||||
|
fastLocationActiveCadenceSeconds:
|
||||||
|
locationTracking.fastLocationActiveCadenceSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> apply(
|
||||||
|
AppSettingsProfileSection? section,
|
||||||
|
AppProvider appProvider,
|
||||||
|
) async {
|
||||||
|
if (section == null || section.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section.mapEnabled != null) {
|
||||||
|
await appProvider.toggleMapEnabled(section.mapEnabled!);
|
||||||
|
}
|
||||||
|
if (section.contactsEnabled != null) {
|
||||||
|
await appProvider.toggleContactsEnabled(section.contactsEnabled!);
|
||||||
|
}
|
||||||
|
if (section.sensorsEnabled != null) {
|
||||||
|
await appProvider.toggleSensorsEnabled(section.sensorsEnabled!);
|
||||||
|
}
|
||||||
|
if (section.voiceSilenceTrimmingEnabled != null) {
|
||||||
|
await appProvider.toggleVoiceSilenceTrimmingEnabled(
|
||||||
|
section.voiceSilenceTrimmingEnabled!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.voiceBandPassFilterEnabled != null) {
|
||||||
|
await appProvider.toggleVoiceBandPassFilterEnabled(
|
||||||
|
section.voiceBandPassFilterEnabled!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.voiceCompressorEnabled != null) {
|
||||||
|
await appProvider.toggleVoiceCompressorEnabled(
|
||||||
|
section.voiceCompressorEnabled!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.voiceLimiterEnabled != null) {
|
||||||
|
await appProvider.toggleVoiceLimiterEnabled(section.voiceLimiterEnabled!);
|
||||||
|
}
|
||||||
|
if (section.voiceAutoGainEnabled != null) {
|
||||||
|
await appProvider.toggleVoiceAutoGainEnabled(
|
||||||
|
section.voiceAutoGainEnabled!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.voiceEchoCancellationEnabled != null) {
|
||||||
|
await appProvider.toggleVoiceEchoCancellationEnabled(
|
||||||
|
section.voiceEchoCancellationEnabled!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.voiceNoiseSuppressionEnabled != null) {
|
||||||
|
await appProvider.toggleVoiceNoiseSuppressionEnabled(
|
||||||
|
section.voiceNoiseSuppressionEnabled!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.messageFontScale != null) {
|
||||||
|
await appProvider.setMessageFontScale(section.messageFontScale!);
|
||||||
|
}
|
||||||
|
if (section.clearPathOnMaxRetry != null) {
|
||||||
|
await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!);
|
||||||
|
}
|
||||||
|
if (section.nearestRelayFallbackEnabled != null) {
|
||||||
|
await appProvider.toggleNearestRelayFallbackEnabled(
|
||||||
|
section.nearestRelayFallbackEnabled!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (section.voiceBitrate != null) {
|
||||||
|
await VoiceBitratePreferences.setBitrate(section.voiceBitrate!);
|
||||||
|
}
|
||||||
|
if (section.routeHashSize != null) {
|
||||||
|
await RouteHashPreferences.setHashSize(section.routeHashSize!);
|
||||||
|
}
|
||||||
|
if (section.imageMaxSize != null) {
|
||||||
|
await ImagePreferences.setMaxSize(section.imageMaxSize!);
|
||||||
|
}
|
||||||
|
if (section.imageCompression != null) {
|
||||||
|
await ImagePreferences.setCompression(section.imageCompression!);
|
||||||
|
}
|
||||||
|
if (section.imageGrayscale != null) {
|
||||||
|
await ImagePreferences.setGrayscale(section.imageGrayscale!);
|
||||||
|
}
|
||||||
|
if (section.imageUltraMode != null) {
|
||||||
|
await ImagePreferences.setUltraMode(section.imageUltraMode!);
|
||||||
|
}
|
||||||
|
if (section.showRxTxIndicators != null) {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool(
|
||||||
|
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||||
|
section.showRxTxIndicators!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final locationTracking = appProvider.locationTrackingService;
|
||||||
|
if (section.fastLocationUpdatesEnabled != null) {
|
||||||
|
locationTracking.fastLocationUpdatesEnabled =
|
||||||
|
section.fastLocationUpdatesEnabled!;
|
||||||
|
}
|
||||||
|
if (section.fastLocationMovementThresholdMeters != null) {
|
||||||
|
locationTracking.fastLocationMovementThresholdMeters =
|
||||||
|
section.fastLocationMovementThresholdMeters!.clamp(10.0, 1000.0);
|
||||||
|
}
|
||||||
|
if (section.fastLocationActiveCadenceSeconds != null) {
|
||||||
|
locationTracking.fastLocationActiveCadenceSeconds =
|
||||||
|
section.fastLocationActiveCadenceSeconds!.clamp(10, 31);
|
||||||
|
}
|
||||||
|
await locationTracking.saveSettings();
|
||||||
|
await appProvider.reloadProfileScopedSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
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';
|
||||||
import 'package:meshcore_client/meshcore_client.dart';
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
import 'profiles_feature_service.dart';
|
||||||
|
|
||||||
/// Background location tracking service for SAR operations
|
/// Background location tracking service for SAR operations
|
||||||
/// Tracks user location and sends periodic updates via MeshCore BLE
|
/// Tracks user location and sends periodic updates via MeshCore BLE
|
||||||
@@ -14,6 +16,11 @@ class BackgroundLocationService {
|
|||||||
static const String _prefKeyLastLon = 'background_last_lon';
|
static const String _prefKeyLastLon = 'background_last_lon';
|
||||||
|
|
||||||
MeshCoreBleService? _bleService;
|
MeshCoreBleService? _bleService;
|
||||||
|
|
||||||
|
String _scopedKey(String baseKey) {
|
||||||
|
return ProfileStorageScope.scopedKey(baseKey);
|
||||||
|
}
|
||||||
|
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
StreamSubscription<Position>? _positionSubscription;
|
StreamSubscription<Position>? _positionSubscription;
|
||||||
|
|
||||||
@@ -58,18 +65,27 @@ 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(_scopedKey(_prefKeyEnabled), true);
|
||||||
await prefs.setDouble(_prefKeyDistance, distanceThreshold);
|
await prefs.setDouble(_scopedKey(_prefKeyDistance), distanceThreshold);
|
||||||
|
|
||||||
// Start listening to position updates
|
// Start listening to position updates
|
||||||
Position? lastPosition;
|
Position? lastPosition;
|
||||||
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 {
|
||||||
@@ -100,8 +116,14 @@ class BackgroundLocationService {
|
|||||||
lastPosition = position;
|
lastPosition = position;
|
||||||
|
|
||||||
// Save to preferences
|
// Save to preferences
|
||||||
await prefs.setDouble(_prefKeyLastLat, position.latitude);
|
await prefs.setDouble(
|
||||||
await prefs.setDouble(_prefKeyLastLon, position.longitude);
|
_scopedKey(_prefKeyLastLat),
|
||||||
|
position.latitude,
|
||||||
|
);
|
||||||
|
await prefs.setDouble(
|
||||||
|
_scopedKey(_prefKeyLastLon),
|
||||||
|
position.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
// Update device's advertised location
|
// Update device's advertised location
|
||||||
if (_bleService != null && _bleService!.isConnected) {
|
if (_bleService != null && _bleService!.isConnected) {
|
||||||
@@ -151,7 +173,7 @@ class BackgroundLocationService {
|
|||||||
_positionSubscription = null;
|
_positionSubscription = null;
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_prefKeyEnabled, false);
|
await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
|
||||||
debugPrint('✅ [BackgroundLocation] Tracking stopped');
|
debugPrint('✅ [BackgroundLocation] Tracking stopped');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,16 +181,34 @@ class BackgroundLocationService {
|
|||||||
/// Note: This will restart tracking with the new threshold
|
/// Note: This will restart tracking with the new threshold
|
||||||
Future<void> updateDistanceThreshold(double distance) async {
|
Future<void> updateDistanceThreshold(double distance) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setDouble(_prefKeyDistance, distance);
|
await prefs.setDouble(_scopedKey(_prefKeyDistance), distance);
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'📏 [BackgroundLocation] Distance threshold updated to ${distance}m',
|
'📏 [BackgroundLocation] Distance threshold updated to ${distance}m',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Restart tracking if currently enabled
|
// Restart tracking if currently enabled
|
||||||
final isEnabled = prefs.getBool(_prefKeyEnabled) ?? false;
|
final isEnabled = prefs.getBool(_scopedKey(_prefKeyEnabled)) ?? false;
|
||||||
if (isEnabled && _bleService != null) {
|
if (isEnabled && _bleService != null) {
|
||||||
await stopTracking();
|
await stopTracking();
|
||||||
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user