mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-13 09:20:29 +00:00
Compare commits
178 Commits
v2026.0308
...
v2026.0324
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
23
.github/workflows/build-artifacts.yml
vendored
23
.github/workflows/build-artifacts.yml
vendored
@@ -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 --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
|
||||||
|
|||||||
3
.github/workflows/build-multiplatform.yml
vendored
3
.github/workflows/build-multiplatform.yml
vendored
@@ -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
|
||||||
|
|||||||
4
.gitignore
vendored
4
.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/
|
||||||
@@ -80,10 +81,11 @@ create_feature_graphic.py
|
|||||||
# Tool caches and local state
|
# Tool caches and local state
|
||||||
.cachebro/
|
.cachebro/
|
||||||
.osgrep/
|
.osgrep/
|
||||||
|
third_party/lpcnet_flutter/
|
||||||
|
|
||||||
# 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`
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -84,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`)
|
||||||
@@ -108,6 +110,7 @@ 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:
|
||||||
@@ -161,6 +164,8 @@ 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: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
|
||||||
@@ -189,6 +194,7 @@ SPEC CHECKSUMS:
|
|||||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||||
|
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||||
|
|
||||||
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
PODFILE CHECKSUM: 2d56c9747241a29800bf539d978b210bda037878
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 106;
|
CURRENT_PROJECT_VERSION = 128;
|
||||||
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 = 106;
|
CURRENT_PROJECT_VERSION = 128;
|
||||||
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 = 106;
|
CURRENT_PROJECT_VERSION = 128;
|
||||||
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 = 106;
|
CURRENT_PROJECT_VERSION = 128;
|
||||||
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 = 106;
|
CURRENT_PROJECT_VERSION = 128;
|
||||||
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 = 106;
|
CURRENT_PROJECT_VERSION = 128;
|
||||||
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,7 +43,7 @@
|
|||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>106</string>
|
<string>128</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
@@ -102,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,24 +5,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000243">
|
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000616">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.511584">
|
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.370543">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="2: build_app" time="95.467725">
|
<testcase classname="fastlane.lanes" name="2: build_app" time="105.689527">
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|
||||||
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="240.60994">
|
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="237.850068">
|
||||||
|
|
||||||
<failure message="/opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/actions/actions_helper.rb:67:in 'Fastlane::Actions.execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:255:in 'block in Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:229:in 'Fastlane::Runner#execute_action' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:157:in 'Fastlane::Runner#trigger_action_by_name' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/fast_file.rb:159:in 'Fastlane::FastFile#method_missing' Fastfile:23:in 'block (2 levels) in Fastlane::FastFile#parsing_binding' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane.rb:41:in 'Fastlane::Lane#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:49:in 'block in Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Dir.chdir' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/runner.rb:45:in 'Fastlane::Runner#execute' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/lane_manager.rb:46:in 'Fastlane::LaneManager.cruise_lane' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/command_line_handler.rb:34:in 'Fastlane::CommandLineHandler.handle' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:110:in 'block (2 levels) in Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:187:in 'Commander::Command#call' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/command.rb:157:in 'Commander::Command#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/runner.rb:444:in 'Commander::Runner#run_active_command' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane_core/lib/fastlane_core/ui/fastlane_runner.rb:124:in 'Commander::Runner#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/commander-4.6.0/lib/commander/delegates.rb:18:in 'Commander::Delegates#run!' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:363:in 'Fastlane::CommandsGenerator#run' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/commands_generator.rb:43:in 'Fastlane::CommandsGenerator.start' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/fastlane/lib/fastlane/cli_tools_distributor.rb:132:in 'Fastlane::CLIToolsDistributor.take_off' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/gems/fastlane-2.231.1/bin/fastlane:23:in '<top (required)>' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Kernel#load' /opt/homebrew/Cellar/ruby/4.0.1/lib/ruby/4.0.0/rubygems.rb:303:in 'Gem.activate_and_load_bin_path' /opt/homebrew/Cellar/fastlane/2.231.1/libexec/bin/fastlane:25:in '<main>' Error uploading ipa file: [Application Loader Error Output]: [ContentDelivery.Uploader.104B12FA0]
|
|
||||||
|
|
||||||
</testcase>
|
</testcase>
|
||||||
|
|
||||||
|
|||||||
@@ -1586,5 +1586,304 @@
|
|||||||
"rawDump": "Rohdaten",
|
"rawDump": "Rohdaten",
|
||||||
"cannotRetryMissingRecipient": "Wiederholung nicht möglich: Empfängerinformationen fehlen",
|
"cannotRetryMissingRecipient": "Wiederholung nicht möglich: Empfängerinformationen fehlen",
|
||||||
"voiceUnavailable": "Sprachnachricht momentan nicht verfügbar",
|
"voiceUnavailable": "Sprachnachricht momentan nicht verfügbar",
|
||||||
"requestingVoice": "Sprachnachricht wird angefordert"
|
"requestingVoice": "Sprachnachricht wird angefordert",
|
||||||
|
"device": "Gerät",
|
||||||
|
"change": "Ändern",
|
||||||
|
"wizardOverviewDescription": "Diese App vereint MeshCore-Nachrichten, SAR-Feldupdates, Karten und Gerätetools an einem Ort.",
|
||||||
|
"wizardOverviewFeature1": "Sende Direkt-, Raum- und Kanalnachrichten aus dem Haupttab \"Nachrichten\".",
|
||||||
|
"wizardOverviewFeature2": "Teile SAR-Markierungen, Kartenzeichnungen, Sprachclips und Bilder über das Mesh.",
|
||||||
|
"wizardOverviewFeature3": "Verbinde dich per BLE oder TCP und verwalte dann das Begleitfunkgerät direkt in der App.",
|
||||||
|
"wizardMessagingTitle": "Nachrichten und Feldberichte",
|
||||||
|
"wizardMessagingDescription": "Nachrichten sind hier mehr als nur Klartext. Die App unterstützt bereits mehrere operative Datentypen und Übertragungsabläufe.",
|
||||||
|
"wizardMessagingFeature1": "Sende Direktnachrichten, Raumbeiträge und Kanalverkehr aus einem einzigen Editor.",
|
||||||
|
"wizardMessagingFeature2": "Erstelle SAR-Updates und wiederverwendbare SAR-Vorlagen für häufige Feldberichte.",
|
||||||
|
"wizardMessagingFeature3": "Übertrage Sprachsitzungen und Bilder mit Fortschritts- und Airtime-Anzeigen in der Oberfläche.",
|
||||||
|
"wizardConnectDeviceTitle": "Gerät verbinden",
|
||||||
|
"wizardConnectDeviceDescription": "Verbinde dein MeshCore-Funkgerät, wähle einen Namen und wende ein Funkprofil an, bevor du fortfährst.",
|
||||||
|
"wizardSetupBadge": "Einrichtung",
|
||||||
|
"wizardOverviewBadge": "Überblick",
|
||||||
|
"wizardConnectedToDevice": "Verbunden mit {deviceName}",
|
||||||
|
"wizardNoDeviceConnected": "Noch kein Gerät verbunden",
|
||||||
|
"wizardSkipForNow": "Vorerst überspringen",
|
||||||
|
"wizardDeviceNameLabel": "Gerätename",
|
||||||
|
"wizardDeviceNameHelp": "Dieser Name wird an andere MeshCore-Nutzer ausgesendet.",
|
||||||
|
"wizardConfigRegionLabel": "Konfigurationsregion",
|
||||||
|
"wizardConfigRegionHelp": "Verwendet die vollständige offizielle Liste der MeshCore-Voreinstellungen. Standard ist EU/UK (Narrow).",
|
||||||
|
"wizardPresetNote1": "Stelle sicher, dass die ausgewählte Voreinstellung zu deinen lokalen Funkvorschriften passt.",
|
||||||
|
"wizardPresetNote2": "Die Liste entspricht dem offiziellen Preset-Feed des MeshCore-Konfigurationstools.",
|
||||||
|
"wizardPresetNote3": "EU/UK (Narrow) bleibt standardmäßig für das Onboarding ausgewählt.",
|
||||||
|
"wizardSaving": "Wird gespeichert...",
|
||||||
|
"wizardSaveAndContinue": "Speichern und fortfahren",
|
||||||
|
"wizardEnterDeviceName": "Gib vor dem Fortfahren einen Gerätenamen ein.",
|
||||||
|
"wizardDeviceSetupSaved": "{deviceName} mit {presetName} gespeichert.",
|
||||||
|
"wizardNetworkTitle": "Kontakte, Räume und Repeater",
|
||||||
|
"wizardNetworkDescription": "Der Kontakte-Tab organisiert das Netzwerk, das du entdeckst, und die Routen, die im Laufe der Zeit gelernt werden.",
|
||||||
|
"wizardNetworkFeature1": "Prüfe Teammitglieder, Repeater, Räume, Kanäle und ausstehende Adverts in einer Liste.",
|
||||||
|
"wizardNetworkFeature2": "Nutze Smart Ping, Raum-Login, gelernte Pfade und Pfad-Reset-Tools, wenn die Verbindung unübersichtlich wird.",
|
||||||
|
"wizardNetworkFeature3": "Erstelle Kanäle und verwalte Netzwerkziele, ohne die App zu verlassen.",
|
||||||
|
"wizardMapOpsTitle": "Karte, Spuren und geteilte Geometrie",
|
||||||
|
"wizardMapOpsDescription": "Die Karte der App ist direkt mit Nachrichten, Tracking und SAR-Overlays verbunden, statt nur ein separater Viewer zu sein.",
|
||||||
|
"wizardMapOpsFeature1": "Verfolge deine eigene Position, Teamstandorte und Bewegungsspuren auf der Karte.",
|
||||||
|
"wizardMapOpsFeature2": "Öffne Zeichnungen aus Nachrichten, prüfe sie inline und entferne sie bei Bedarf von der Karte.",
|
||||||
|
"wizardMapOpsFeature3": "Nutze Repeater-Kartenansichten und geteilte Overlays, um die Netzabdeckung im Feld zu verstehen.",
|
||||||
|
"wizardToolsTitle": "Werkzeuge jenseits von Nachrichten",
|
||||||
|
"wizardToolsDescription": "Es gibt hier mehr als nur die vier Haupttabs. Die App enthält außerdem Konfiguration, Diagnose und optionale Sensor-Workflows.",
|
||||||
|
"wizardToolsFeature1": "Öffne die Gerätekonfiguration, um Funkparameter, Telemetrie, Sendeleistung und Begleitdetails zu ändern.",
|
||||||
|
"wizardToolsFeature2": "Aktiviere den Sensoren-Tab, wenn du überwachte Sensor-Dashboards und schnelle Aktualisierungen möchtest.",
|
||||||
|
"wizardToolsFeature3": "Nutze Paketprotokolle, Spektrumsuche und Entwicklerdiagnosen zur Fehlersuche im Mesh.",
|
||||||
|
"contactInSensors": "In Sensoren",
|
||||||
|
"contactAddToSensors": "Zu Sensoren hinzufügen",
|
||||||
|
"contactSetPath": "Pfad festlegen",
|
||||||
|
"contactAddedToSensors": "{contactName} zu Sensoren hinzugefügt",
|
||||||
|
"contactFailedToClearRoute": "Pfad konnte nicht gelöscht werden: {error}",
|
||||||
|
"contactRouteCleared": "Pfad gelöscht",
|
||||||
|
"contactRouteSet": "Pfad gesetzt: {route}",
|
||||||
|
"contactFailedToSetRoute": "Pfad konnte nicht gesetzt werden: {error}",
|
||||||
|
"rssi": "RSSI",
|
||||||
|
"snr": "SNR",
|
||||||
|
"ackTimeout": "ACK-Zeitüberschreitung",
|
||||||
|
"opcode": "Opcode",
|
||||||
|
"payload": "Nutzlast",
|
||||||
|
"hops": "Hops",
|
||||||
|
"hashSize": "Hash-Größe",
|
||||||
|
"pathBytes": "Pfad-Bytes",
|
||||||
|
"selectedPath": "Ausgewählter Pfad",
|
||||||
|
"estimatedTx": "Geschätzte Sendung",
|
||||||
|
"senderToReceipt": "Sender bis Empfang",
|
||||||
|
"receivedCopies": "Empfangene Kopien",
|
||||||
|
"retryCause": "Wiederholungsursache",
|
||||||
|
"retryMode": "Wiederholungsmodus",
|
||||||
|
"retryResult": "Wiederholungsergebnis",
|
||||||
|
"lastRetry": "Letzter Versuch",
|
||||||
|
"rxPackets": "RX-Pakete",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Rate",
|
||||||
|
"window": "Fenster",
|
||||||
|
"posttxDelay": "Sendeverzögerung",
|
||||||
|
"bandpass": "Bandpass",
|
||||||
|
"bandpassFilterVoice": "Bandpassfilter Sprache",
|
||||||
|
"frequency": "Frequenz",
|
||||||
|
"australia": "Australien",
|
||||||
|
"australiaNarrow": "Australien (Schmal)",
|
||||||
|
"australiaQld": "Australien: QLD",
|
||||||
|
"australiaSaWa": "Australien: SA, WA",
|
||||||
|
"newZealand": "Neuseeland",
|
||||||
|
"newZealandNarrow": "Neuseeland (Schmal)",
|
||||||
|
"switzerland": "Schweiz",
|
||||||
|
"portugal433": "Portugal 433",
|
||||||
|
"portugal868": "Portugal 868",
|
||||||
|
"czechRepublicNarrow": "Tschechien (Schmal)",
|
||||||
|
"eu433mhzLongRange": "EU 433MHz (Langstrecke)",
|
||||||
|
"euukDeprecated": "EU/UK (Veraltet)",
|
||||||
|
"euukNarrow": "EU/UK (Schmal)",
|
||||||
|
"usacanadaRecommended": "USA/Kanada (Empfohlen)",
|
||||||
|
"vietnamDeprecated": "Vietnam (Veraltet)",
|
||||||
|
"vietnamNarrow": "Vietnam (Schmal)",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"addContact": "Kontakt hinzufügen",
|
||||||
|
"all": "Alle",
|
||||||
|
"autoResolve": "Automatisch auflösen",
|
||||||
|
"clearAllLabel": "Alle löschen",
|
||||||
|
"clearRelays": "Relais löschen",
|
||||||
|
"clearFilters": "Filter löschen",
|
||||||
|
"clearRoute": "Route löschen",
|
||||||
|
"clearMessages": "Nachrichten löschen",
|
||||||
|
"clearScale": "Maßstab löschen",
|
||||||
|
"clearDiscoveries": "Entdeckungen löschen",
|
||||||
|
"clearOnlineTraceDatabase": "Online-Trace-Datenbank löschen",
|
||||||
|
"clearAllChannels": "Alle Kanäle löschen",
|
||||||
|
"clearAllContacts": "Alle Kontakte löschen",
|
||||||
|
"clearChannels": "Kanäle löschen",
|
||||||
|
"clearContacts": "Kontakte löschen",
|
||||||
|
"clearPathOnMaxRetry": "Pfad bei max. Wiederholung löschen",
|
||||||
|
"create": "Erstellen",
|
||||||
|
"custom": "Benutzerdefiniert",
|
||||||
|
"defaultValue": "Standard",
|
||||||
|
"duplicate": "Duplizieren",
|
||||||
|
"editName": "Name bearbeiten",
|
||||||
|
"open": "Öffnen",
|
||||||
|
"paste": "Einfügen",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"remove": "Entfernen",
|
||||||
|
"rename": "Umbenennen",
|
||||||
|
"resolveAll": "Alle auflösen",
|
||||||
|
"send": "Senden",
|
||||||
|
"sendAnyway": "Trotzdem senden",
|
||||||
|
"share": "Teilen",
|
||||||
|
"shareContact": "Kontakt teilen",
|
||||||
|
"trace": "Trace",
|
||||||
|
"use": "Verwenden",
|
||||||
|
"useSelectedFrequency": "Ausgewählte Frequenz verwenden",
|
||||||
|
"discovery": "Erkennung",
|
||||||
|
"discoverRepeaters": "Repeater entdecken",
|
||||||
|
"discoverSensors": "Sensoren entdecken",
|
||||||
|
"repeaterDiscoverySent": "Repeater-Erkennung gesendet",
|
||||||
|
"sensorDiscoverySent": "Sensor-Erkennung gesendet",
|
||||||
|
"clearedPendingDiscoveries": "Ausstehende Entdeckungen gelöscht.",
|
||||||
|
"autoDiscovery": "Automatische Erkennung",
|
||||||
|
"enableAutomaticAdding": "Automatisches Hinzufügen aktivieren",
|
||||||
|
"autoaddRepeaters": "Repeater automatisch hinzufügen",
|
||||||
|
"autoaddRoomServers": "Raumserver automatisch hinzufügen",
|
||||||
|
"autoaddSensors": "Sensoren automatisch hinzufügen",
|
||||||
|
"autoaddUsers": "Benutzer automatisch hinzufügen",
|
||||||
|
"overwriteOldestWhenFull": "Älteste bei vollem Speicher überschreiben",
|
||||||
|
"storage": "Speicher",
|
||||||
|
"dangerZone": "Gefahrenbereich",
|
||||||
|
"profiles": "Profile",
|
||||||
|
"favourites": "Favoriten",
|
||||||
|
"sensors": "Sensoren",
|
||||||
|
"others": "Andere",
|
||||||
|
"gpsModule": "GPS-Modul",
|
||||||
|
"liveTraffic": "Live-Verkehr",
|
||||||
|
"repeatersMap": "Repeater-Karte",
|
||||||
|
"spectrumScan": "Spektrum-Scan",
|
||||||
|
"blePacketLogs": "BLE-Paketprotokolle",
|
||||||
|
"onlineTraceDatabase": "Online-Trace-Datenbank",
|
||||||
|
"routePathByteSize": "Pfad-Bytegröße",
|
||||||
|
"messageNotifications": "Nachrichtenbenachrichtigungen",
|
||||||
|
"sarAlerts": "SAR-Alarme",
|
||||||
|
"discoveryNotifications": "Erkennungsbenachrichtigungen",
|
||||||
|
"updateNotifications": "Updatebenachrichtigungen",
|
||||||
|
"muteWhileAppIsOpen": "Stumm bei geöffneter App",
|
||||||
|
"disableContacts": "Kontakte deaktivieren",
|
||||||
|
"enableSensorsTab": "Sensoren-Tab aktivieren",
|
||||||
|
"enableProfiles": "Profile aktivieren",
|
||||||
|
"autoRouteRotation": "Automatische Routenrotation",
|
||||||
|
"nearestRepeaterFallback": "Nächster Repeater als Fallback",
|
||||||
|
"deleteAllStoredMessageHistory": "Gesamten Nachrichtenverlauf löschen",
|
||||||
|
"messageFontSize": "Nachrichtenschriftgröße",
|
||||||
|
"rotateMapWithHeading": "Karte mit Richtung drehen",
|
||||||
|
"showMapDebugInfo": "Karten-Debug-Info anzeigen",
|
||||||
|
"openMapInFullscreen": "Karte im Vollbild öffnen",
|
||||||
|
"showSarMarkersLabel": "SAR-Markierungen anzeigen",
|
||||||
|
"displaySarMarkersOnTheMainMap": "SAR-Markierungen auf der Hauptkarte anzeigen",
|
||||||
|
"showAllContactTrailsLabel": "Alle Kontaktpfade anzeigen",
|
||||||
|
"hideRepeatersOnMap": "Repeater auf Karte ausblenden",
|
||||||
|
"setMapScale": "Kartenmaßstab einstellen",
|
||||||
|
"customMapScaleSaved": "Benutzerdefinierter Kartenmaßstab gespeichert",
|
||||||
|
"voiceBitrate": "Sprach-Bitrate",
|
||||||
|
"voiceCompressor": "Sprachkompressor",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Gleicht leise und laute Sprache aus",
|
||||||
|
"voiceLimiter": "Sprach-Limiter",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Verhindert Clipping-Spitzen vor der Kodierung",
|
||||||
|
"micAutoGain": "Mikrofon-Automatik",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Lässt den Rekorder den Eingangspegel anpassen",
|
||||||
|
"echoCancellation": "Echounterdrückung",
|
||||||
|
"noiseSuppression": "Rauschunterdrückung",
|
||||||
|
"trimSilenceInVoiceMessages": "Stille in Sprachnachrichten trimmen",
|
||||||
|
"compressor": "Kompressor",
|
||||||
|
"limiter": "Limiter",
|
||||||
|
"autoGain": "Automatische Verstärkung",
|
||||||
|
"echoCancel": "Echo",
|
||||||
|
"noiseSuppress": "Rauschen",
|
||||||
|
"silenceTrim": "Stille",
|
||||||
|
"maxImageSize": "Maximale Bildgröße",
|
||||||
|
"imageCompression": "Bildkomprimierung",
|
||||||
|
"grayscale": "Graustufen",
|
||||||
|
"ultraMode": "Ultra-Modus",
|
||||||
|
"fastPrivateGpsUpdates": "Schnelle private GPS-Updates",
|
||||||
|
"movementThreshold": "Bewegungsschwelle",
|
||||||
|
"fastGpsMovementThreshold": "Schneller GPS-Bewegungsschwellenwert",
|
||||||
|
"fastGpsActiveuseInterval": "Schnelles GPS aktive Nutzung Intervall",
|
||||||
|
"activeuseUpdateInterval": "Aktive Nutzung Updateintervall",
|
||||||
|
"repeatNearbyTraffic": "Nahverkehr wiederholen",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Über Repeater im Mesh weiterleiten",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Nur in der Nähe, ohne Repeater-Flooding",
|
||||||
|
"multihop": "Multi-Hop",
|
||||||
|
"createProfile": "Profil erstellen",
|
||||||
|
"renameProfile": "Profil umbenennen",
|
||||||
|
"newProfile": "Neues Profil",
|
||||||
|
"manageProfiles": "Profile verwalten",
|
||||||
|
"enableProfilesToStartManagingThem": "Aktivieren Sie Profile, um sie zu verwalten.",
|
||||||
|
"openMessage": "Nachricht öffnen",
|
||||||
|
"jumpToTheRelatedSarMessage": "Zur zugehörigen SAR-Nachricht springen",
|
||||||
|
"removeSarMarker": "SAR-Markierung entfernen",
|
||||||
|
"pleaseSelectADestinationToSendSarMarker": "Bitte wählen Sie ein Ziel zum Senden der SAR-Markierung",
|
||||||
|
"sarMarkerBroadcastToPublicChannel": "SAR-Markierung an öffentlichen Kanal gesendet",
|
||||||
|
"sarMarkerSentToRoom": "SAR-Markierung an Raum gesendet",
|
||||||
|
"loadFromGallery": "Aus Galerie laden",
|
||||||
|
"replaceImage": "Bild ersetzen",
|
||||||
|
"selectFromGallery": "Aus Galerie auswählen",
|
||||||
|
"team": "Team",
|
||||||
|
"found": "Gefunden",
|
||||||
|
"staging": "Sammelpunkt",
|
||||||
|
"object": "Objekt",
|
||||||
|
"quiet": "Ruhig",
|
||||||
|
"moderate": "Mäßig",
|
||||||
|
"busy": "Beschäftigt",
|
||||||
|
"spectrumScanReturnedNoCandidateFrequencies": "Spektrum-Scan hat keine Kandidatenfrequenzen gefunden",
|
||||||
|
"searchMessages": "Nachrichten suchen",
|
||||||
|
"sendImageFromGallery": "Bild aus Galerie senden",
|
||||||
|
"takePhoto": "Foto aufnehmen",
|
||||||
|
"dmOnly": "Nur Direktnachricht",
|
||||||
|
"allMessages": "Alle Nachrichten",
|
||||||
|
"sendToPublicChannel": "An öffentlichen Kanal senden?",
|
||||||
|
"selectMarkerTypeAndDestination": "Markierungstyp und Ziel auswählen",
|
||||||
|
"noDestinationsAvailableLabel": "Keine Ziele verfügbar",
|
||||||
|
"image": "Bild",
|
||||||
|
"format": "Format",
|
||||||
|
"dimensions": "Abmessungen",
|
||||||
|
"segments": "Segmente",
|
||||||
|
"transfers": "Übertragungen",
|
||||||
|
"downloadedBy": "Heruntergeladen von",
|
||||||
|
"saveDiscoverySettings": "Erkennungseinstellungen speichern",
|
||||||
|
"savePublicInfo": "Öffentliche Info speichern",
|
||||||
|
"saveRadioSettings": "Funkeinstellungen speichern",
|
||||||
|
"savePath": "Pfad speichern",
|
||||||
|
"wipeDeviceData": "Gerätedaten löschen",
|
||||||
|
"wipeDevice": "Gerät löschen",
|
||||||
|
"destructiveDeviceActions": "Destruktive Geräteaktionen.",
|
||||||
|
"chooseAPresetOrFinetuneCustomRadioSettings": "Wählen Sie eine Voreinstellung oder passen Sie die Funkeinstellungen an.",
|
||||||
|
"chooseTheNameAndLocationThisDeviceShares": "Wählen Sie den Namen und Standort, den dieses Gerät teilt.",
|
||||||
|
"availableSpaceOnThisDevice": "Verfügbarer Speicherplatz auf diesem Gerät.",
|
||||||
|
"used": "Belegt",
|
||||||
|
"total": "Gesamt",
|
||||||
|
"renameValue": "Wert umbenennen",
|
||||||
|
"customizeFields": "Felder anpassen",
|
||||||
|
"livePreview": "Live-Vorschau",
|
||||||
|
"refreshSchedule": "Aktualisierungsplan",
|
||||||
|
"noResponse": "Keine Antwort",
|
||||||
|
"refreshing": "Wird aktualisiert",
|
||||||
|
"unavailable": "Nicht verfügbar",
|
||||||
|
"pickARelayOrNodeToWatchInSensors": "Wählen Sie einen Relay oder Knoten zur Beobachtung.",
|
||||||
|
"publicKeyLabel": "Öffentlicher Schlüssel",
|
||||||
|
"alreadyInContacts": "Bereits in Kontakten",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Verbinden Sie sich mit einem Gerät, bevor Sie Kontakte hinzufügen",
|
||||||
|
"fromContacts": "Aus Kontakten",
|
||||||
|
"onlineOnly": "Nur online",
|
||||||
|
"inBoth": "In beiden",
|
||||||
|
"source": "Quelle",
|
||||||
|
"manualRouteEdit": "Manuelle Routenbearbeitung",
|
||||||
|
"observedMeshRoute": "Beobachtete Mesh-Route",
|
||||||
|
"allMessagesCleared": "Alle Nachrichten gelöscht",
|
||||||
|
"onlineTraceDatabaseCleared": "Online-Trace-Datenbank gelöscht",
|
||||||
|
"packetLogsCleared": "Paketprotokolle gelöscht",
|
||||||
|
"hexDataCopiedToClipboard": "Hex-Daten in die Zwischenablage kopiert",
|
||||||
|
"developerModeEnabled": "Entwicklermodus aktiviert",
|
||||||
|
"developerModeDisabled": "Entwicklermodus deaktiviert",
|
||||||
|
"clipboardIsEmpty": "Zwischenablage ist leer",
|
||||||
|
"contactImported": "Kontakt importiert",
|
||||||
|
"contactLinkCopiedToClipboard": "Kontaktlink in Zwischenablage kopiert",
|
||||||
|
"failedToExportContact": "Kontaktexport fehlgeschlagen",
|
||||||
|
"noLogsToExport": "Keine Protokolle zum Exportieren",
|
||||||
|
"exportAsCsv": "Als CSV exportieren",
|
||||||
|
"exportAsText": "Als Text exportieren",
|
||||||
|
"receivedRfc3339": "Empfangen (RFC3339)",
|
||||||
|
"buildTime": "Build-Zeit",
|
||||||
|
"downloadUrlNotAvailable": "Download-URL nicht verfügbar",
|
||||||
|
"cannotOpenDownloadUrl": "Download-URL kann nicht geöffnet werden",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "Update-Prüfung nur auf Android verfügbar",
|
||||||
|
"youAreRunningTheLatestVersion": "Sie verwenden die neueste Version",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "Update verfügbar, aber Download-URL nicht gefunden",
|
||||||
|
"startTictactoe": "Tic-Tac-Toe starten",
|
||||||
|
"tictactoeUnavailable": "Tic-Tac-Toe nicht verfügbar",
|
||||||
|
"tictactoeOpponentUnknown": "Tic-Tac-Toe: Gegner unbekannt",
|
||||||
|
"tictactoeWaitingForStart": "Tic-Tac-Toe: Warten auf Start",
|
||||||
|
"acceptsShareLinks": "Akzeptiert freigegebene Links",
|
||||||
|
"supportsRawHex": "Unterstützt rohes Hex",
|
||||||
|
"clipboardfriendly": "Zwischenablage-freundlich",
|
||||||
|
"captured": "Erfasst",
|
||||||
|
"size": "Größe",
|
||||||
|
"noCustomChannelsToClear": "Keine benutzerdefinierten Kanäle zum Löschen.",
|
||||||
|
"noDeviceContactsToClear": "Keine Gerätekontakte zum Löschen."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2251,5 +2251,304 @@
|
|||||||
"rawDump": "Ακατέργαστα δεδομένα",
|
"rawDump": "Ακατέργαστα δεδομένα",
|
||||||
"cannotRetryMissingRecipient": "Δεν είναι δυνατή η επανάληψη: λείπουν πληροφορίες παραλήπτη",
|
"cannotRetryMissingRecipient": "Δεν είναι δυνατή η επανάληψη: λείπουν πληροφορίες παραλήπτη",
|
||||||
"voiceUnavailable": "Η φωνή δεν είναι διαθέσιμη αυτή τη στιγμή",
|
"voiceUnavailable": "Η φωνή δεν είναι διαθέσιμη αυτή τη στιγμή",
|
||||||
"requestingVoice": "Αίτηση φωνής"
|
"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.",
|
||||||
|
"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": "Opcode",
|
||||||
|
"payload": "Ωφέλιμο φορτίο",
|
||||||
|
"hops": "Αναπηδήσεις",
|
||||||
|
"hashSize": "Μέγεθος hash",
|
||||||
|
"pathBytes": "Bytes διαδρομής",
|
||||||
|
"selectedPath": "Επιλεγμένη διαδρομή",
|
||||||
|
"estimatedTx": "Εκτιμώμενη εκπομπή",
|
||||||
|
"senderToReceipt": "Αποστολέας σε απόδειξη",
|
||||||
|
"receivedCopies": "Ληφθέντα αντίγραφα",
|
||||||
|
"retryCause": "Αιτία επανάληψης",
|
||||||
|
"retryMode": "Λειτουργία επανάληψης",
|
||||||
|
"retryResult": "Αποτέλεσμα επανάληψης",
|
||||||
|
"lastRetry": "Τελευταία επανάληψη",
|
||||||
|
"rxPackets": "Πακέτα RX",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Ρυθμός",
|
||||||
|
"window": "Παράθυρο",
|
||||||
|
"posttxDelay": "Καθυστέρηση μετά-tx",
|
||||||
|
"bandpass": "Ζωνοπερατό",
|
||||||
|
"bandpassFilterVoice": "Ζωνοπερατό φίλτρο φωνής",
|
||||||
|
"frequency": "Συχνότητα",
|
||||||
|
"australia": "Αυστραλία",
|
||||||
|
"australiaNarrow": "Αυστραλία (Στενό)",
|
||||||
|
"australiaQld": "Αυστραλία: QLD",
|
||||||
|
"australiaSaWa": "Αυστραλία: SA, WA",
|
||||||
|
"newZealand": "Νέα Ζηλανδία",
|
||||||
|
"newZealandNarrow": "Νέα Ζηλανδία (Στενό)",
|
||||||
|
"switzerland": "Ελβετία",
|
||||||
|
"portugal433": "Πορτογαλία 433",
|
||||||
|
"portugal868": "Πορτογαλία 868",
|
||||||
|
"czechRepublicNarrow": "Τσεχία (Στενό)",
|
||||||
|
"eu433mhzLongRange": "ΕΕ 433MHz (Μεγάλη Εμβέλεια)",
|
||||||
|
"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": "Μέγεθος διαδρομής σε bytes",
|
||||||
|
"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": "Ρυθμός bit φωνής",
|
||||||
|
"voiceCompressor": "Συμπιεστής φωνής",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Εξισορρόπηση χαμηλών και δυνατών επιπέδων",
|
||||||
|
"voiceLimiter": "Περιοριστής φωνής",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Αποτρέπει κοπή κορυφών πριν την κωδικοποίηση",
|
||||||
|
"micAutoGain": "Αυτόματη ενίσχυση μικροφώνου",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Αφήνει τη συσκευή να ρυθμίσει την ένταση",
|
||||||
|
"echoCancellation": "Ακύρωση ηχούς",
|
||||||
|
"noiseSuppression": "Καταστολή θορύβου",
|
||||||
|
"trimSilenceInVoiceMessages": "Περικοπή σιωπής σε φωνητικά μηνύματα",
|
||||||
|
"compressor": "Συμπιεστής",
|
||||||
|
"limiter": "Περιοριστής",
|
||||||
|
"autoGain": "Αυτόματη ενίσχυση",
|
||||||
|
"echoCancel": "Ηχώ",
|
||||||
|
"noiseSuppress": "Θόρυβος",
|
||||||
|
"silenceTrim": "Σιωπή",
|
||||||
|
"maxImageSize": "Μέγιστο μέγεθος εικόνας",
|
||||||
|
"imageCompression": "Συμπίεση εικόνας",
|
||||||
|
"grayscale": "Κλίμακα του γκρι",
|
||||||
|
"ultraMode": "Λειτουργία ultra",
|
||||||
|
"fastPrivateGpsUpdates": "Γρήγορες ιδιωτικές ενημερώσεις GPS",
|
||||||
|
"movementThreshold": "Κατώφλι κίνησης",
|
||||||
|
"fastGpsMovementThreshold": "Κατώφλι κίνησης γρήγορου GPS",
|
||||||
|
"fastGpsActiveuseInterval": "Διάστημα ενεργής χρήσης γρήγορου GPS",
|
||||||
|
"activeuseUpdateInterval": "Διάστημα ενημέρωσης ενεργής χρήσης",
|
||||||
|
"repeatNearbyTraffic": "Επανάληψη κοντινής κίνησης",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Αναμετάδοση μέσω αναμεταδοτών στο δίκτυο",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Μόνο κοντά, χωρίς πλημμυρισμό",
|
||||||
|
"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": "Επιλέξτε ένα relay ή κόμβο για παρακολούθηση.",
|
||||||
|
"publicKeyLabel": "Δημόσιο κλειδί",
|
||||||
|
"alreadyInContacts": "Ήδη στις επαφές",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Συνδεθείτε σε μια συσκευή πριν προσθέσετε επαφές",
|
||||||
|
"fromContacts": "Από τις επαφές",
|
||||||
|
"onlineOnly": "Μόνο συνδεδεμένοι",
|
||||||
|
"inBoth": "Σε αμφότερα",
|
||||||
|
"source": "Πηγή",
|
||||||
|
"manualRouteEdit": "Χειροκίνητη επεξεργασία διαδρομής",
|
||||||
|
"observedMeshRoute": "Παρατηρούμενη διαδρομή δικτύου",
|
||||||
|
"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": "Υποστηρίζει ακατέργαστο hex",
|
||||||
|
"clipboardfriendly": "Φιλικό προς πρόχειρο",
|
||||||
|
"captured": "Καταγράφηκε",
|
||||||
|
"size": "Μέγεθος",
|
||||||
|
"noCustomChannelsToClear": "Δεν υπάρχουν προσαρμοσμένα κανάλια.",
|
||||||
|
"noDeviceContactsToClear": "Δεν υπάρχουν επαφές συσκευής."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2228,78 +2228,574 @@
|
|||||||
"description": "Message when there are no chat contacts to send to"
|
"description": "Message when there are no chat contacts to send to"
|
||||||
},
|
},
|
||||||
"reply": "Reply",
|
"reply": "Reply",
|
||||||
"@reply": {
|
"@reply": {},
|
||||||
},
|
|
||||||
"technicalDetails": "Technical details",
|
"technicalDetails": "Technical details",
|
||||||
"@technicalDetails": {
|
"@technicalDetails": {},
|
||||||
},
|
|
||||||
"messageTechnicalDetails": "Message technical details",
|
"messageTechnicalDetails": "Message technical details",
|
||||||
"@messageTechnicalDetails": {
|
"@messageTechnicalDetails": {},
|
||||||
},
|
|
||||||
"linkQuality": "Link quality",
|
"linkQuality": "Link quality",
|
||||||
"@linkQuality": {
|
"@linkQuality": {},
|
||||||
},
|
|
||||||
"delivery": "Delivery",
|
"delivery": "Delivery",
|
||||||
"@delivery": {
|
"@delivery": {},
|
||||||
},
|
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"@status": {
|
"@status": {},
|
||||||
},
|
|
||||||
"expectedAckTag": "Expected ACK tag",
|
"expectedAckTag": "Expected ACK tag",
|
||||||
"@expectedAckTag": {
|
"@expectedAckTag": {},
|
||||||
},
|
|
||||||
"roundTrip": "Round-trip",
|
"roundTrip": "Round-trip",
|
||||||
"@roundTrip": {
|
"@roundTrip": {},
|
||||||
},
|
|
||||||
"retryAttempt": "Retry attempt",
|
"retryAttempt": "Retry attempt",
|
||||||
"@retryAttempt": {
|
"@retryAttempt": {},
|
||||||
},
|
|
||||||
"floodFallback": "Flood fallback",
|
"floodFallback": "Flood fallback",
|
||||||
"@floodFallback": {
|
"@floodFallback": {},
|
||||||
},
|
|
||||||
"identity": "Identity",
|
"identity": "Identity",
|
||||||
"@identity": {
|
"@identity": {},
|
||||||
},
|
|
||||||
"messageId": "Message ID",
|
"messageId": "Message ID",
|
||||||
"@messageId": {
|
"@messageId": {},
|
||||||
},
|
|
||||||
"sender": "Sender",
|
"sender": "Sender",
|
||||||
"@sender": {
|
"@sender": {},
|
||||||
},
|
|
||||||
"senderKey": "Sender key",
|
"senderKey": "Sender key",
|
||||||
"@senderKey": {
|
"@senderKey": {},
|
||||||
},
|
|
||||||
"recipient": "Recipient",
|
"recipient": "Recipient",
|
||||||
"@recipient": {
|
"@recipient": {},
|
||||||
},
|
|
||||||
"recipientKey": "Recipient key",
|
"recipientKey": "Recipient key",
|
||||||
"@recipientKey": {
|
"@recipientKey": {},
|
||||||
},
|
|
||||||
"voice": "Voice",
|
"voice": "Voice",
|
||||||
"@voice": {
|
"@voice": {},
|
||||||
},
|
|
||||||
"voiceId": "Voice ID",
|
"voiceId": "Voice ID",
|
||||||
"@voiceId": {
|
"@voiceId": {},
|
||||||
},
|
|
||||||
"envelope": "Envelope",
|
"envelope": "Envelope",
|
||||||
"@envelope": {
|
"@envelope": {},
|
||||||
},
|
|
||||||
"sessionProgress": "Session progress",
|
"sessionProgress": "Session progress",
|
||||||
"@sessionProgress": {
|
"@sessionProgress": {},
|
||||||
},
|
|
||||||
"complete": "Complete",
|
"complete": "Complete",
|
||||||
"@complete": {
|
"@complete": {},
|
||||||
},
|
|
||||||
"rawDump": "Raw dump",
|
"rawDump": "Raw dump",
|
||||||
"@rawDump": {
|
"@rawDump": {},
|
||||||
},
|
|
||||||
"cannotRetryMissingRecipient": "Cannot retry: recipient information missing",
|
"cannotRetryMissingRecipient": "Cannot retry: recipient information missing",
|
||||||
"@cannotRetryMissingRecipient": {
|
"@cannotRetryMissingRecipient": {},
|
||||||
},
|
|
||||||
"voiceUnavailable": "Voice unavailable right now",
|
"voiceUnavailable": "Voice unavailable right now",
|
||||||
"@voiceUnavailable": {
|
"@voiceUnavailable": {},
|
||||||
},
|
|
||||||
"requestingVoice": "Requesting voice",
|
"requestingVoice": "Requesting voice",
|
||||||
"@requestingVoice": {
|
"@requestingVoice": {},
|
||||||
}
|
"device": "device",
|
||||||
|
"@device": {
|
||||||
|
"description": "Generic device label"
|
||||||
|
},
|
||||||
|
"change": "Change",
|
||||||
|
"@change": {
|
||||||
|
"description": "Generic change action"
|
||||||
|
},
|
||||||
|
"wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.",
|
||||||
|
"@wizardOverviewDescription": {
|
||||||
|
"description": "Updated onboarding overview description"
|
||||||
|
},
|
||||||
|
"wizardOverviewFeature1": "Send direct, room, and channel messages from the main Messages tab.",
|
||||||
|
"@wizardOverviewFeature1": {
|
||||||
|
"description": "Updated onboarding overview feature 1"
|
||||||
|
},
|
||||||
|
"wizardOverviewFeature2": "Share SAR markers, map drawings, voice clips, and images over the mesh.",
|
||||||
|
"@wizardOverviewFeature2": {
|
||||||
|
"description": "Updated onboarding overview feature 2"
|
||||||
|
},
|
||||||
|
"wizardOverviewFeature3": "Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.",
|
||||||
|
"@wizardOverviewFeature3": {
|
||||||
|
"description": "Updated onboarding overview feature 3"
|
||||||
|
},
|
||||||
|
"wizardMessagingTitle": "Messaging and Field Reports",
|
||||||
|
"@wizardMessagingTitle": {
|
||||||
|
"description": "Updated onboarding messaging page title"
|
||||||
|
},
|
||||||
|
"wizardMessagingDescription": "Messages are more than plain text here. The app already supports several operational payloads and transfer workflows.",
|
||||||
|
"@wizardMessagingDescription": {
|
||||||
|
"description": "Updated onboarding messaging page description"
|
||||||
|
},
|
||||||
|
"wizardMessagingFeature1": "Send direct messages, room posts, and channel traffic from one composer.",
|
||||||
|
"@wizardMessagingFeature1": {
|
||||||
|
"description": "Updated onboarding messaging feature 1"
|
||||||
|
},
|
||||||
|
"wizardMessagingFeature2": "Create SAR updates and reusable SAR templates for common field reports.",
|
||||||
|
"@wizardMessagingFeature2": {
|
||||||
|
"description": "Updated onboarding messaging feature 2"
|
||||||
|
},
|
||||||
|
"wizardMessagingFeature3": "Transfer voice sessions and images, with progress and airtime estimates in the UI.",
|
||||||
|
"@wizardMessagingFeature3": {
|
||||||
|
"description": "Updated onboarding messaging feature 3"
|
||||||
|
},
|
||||||
|
"wizardConnectDeviceTitle": "Connect device",
|
||||||
|
"@wizardConnectDeviceTitle": {
|
||||||
|
"description": "Onboarding connect device page title"
|
||||||
|
},
|
||||||
|
"wizardConnectDeviceDescription": "Connect your MeshCore radio, choose a name, and apply a radio preset before continuing.",
|
||||||
|
"@wizardConnectDeviceDescription": {
|
||||||
|
"description": "Onboarding connect device page description"
|
||||||
|
},
|
||||||
|
"wizardSetupBadge": "Setup",
|
||||||
|
"@wizardSetupBadge": {
|
||||||
|
"description": "Onboarding setup badge label"
|
||||||
|
},
|
||||||
|
"wizardOverviewBadge": "Overview",
|
||||||
|
"@wizardOverviewBadge": {
|
||||||
|
"description": "Onboarding overview badge label"
|
||||||
|
},
|
||||||
|
"wizardConnectedToDevice": "Connected to {deviceName}",
|
||||||
|
"@wizardConnectedToDevice": {
|
||||||
|
"description": "Onboarding connection status when connected",
|
||||||
|
"placeholders": {
|
||||||
|
"deviceName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wizardNoDeviceConnected": "No device connected yet",
|
||||||
|
"@wizardNoDeviceConnected": {
|
||||||
|
"description": "Onboarding connection status when disconnected"
|
||||||
|
},
|
||||||
|
"wizardSkipForNow": "Skip for now",
|
||||||
|
"@wizardSkipForNow": {
|
||||||
|
"description": "Onboarding skip current setup step action"
|
||||||
|
},
|
||||||
|
"wizardDeviceNameLabel": "Device name",
|
||||||
|
"@wizardDeviceNameLabel": {
|
||||||
|
"description": "Onboarding device name field label"
|
||||||
|
},
|
||||||
|
"wizardDeviceNameHelp": "This name is advertised to other MeshCore users.",
|
||||||
|
"@wizardDeviceNameHelp": {
|
||||||
|
"description": "Onboarding device name field helper text"
|
||||||
|
},
|
||||||
|
"wizardConfigRegionLabel": "Config region",
|
||||||
|
"@wizardConfigRegionLabel": {
|
||||||
|
"description": "Onboarding config region field label"
|
||||||
|
},
|
||||||
|
"wizardConfigRegionHelp": "Uses the full official MeshCore preset list. Default is EU/UK (Narrow).",
|
||||||
|
"@wizardConfigRegionHelp": {
|
||||||
|
"description": "Onboarding config region helper text"
|
||||||
|
},
|
||||||
|
"wizardPresetNote1": "Make sure the selected preset matches your local radio regulations.",
|
||||||
|
"@wizardPresetNote1": {
|
||||||
|
"description": "Onboarding preset note 1"
|
||||||
|
},
|
||||||
|
"wizardPresetNote2": "The list matches the official MeshCore config tool preset feed.",
|
||||||
|
"@wizardPresetNote2": {
|
||||||
|
"description": "Onboarding preset note 2"
|
||||||
|
},
|
||||||
|
"wizardPresetNote3": "EU/UK (Narrow) stays selected by default for onboarding.",
|
||||||
|
"@wizardPresetNote3": {
|
||||||
|
"description": "Onboarding preset note 3"
|
||||||
|
},
|
||||||
|
"wizardSaving": "Saving...",
|
||||||
|
"@wizardSaving": {
|
||||||
|
"description": "Onboarding saving button label"
|
||||||
|
},
|
||||||
|
"wizardSaveAndContinue": "Save and continue",
|
||||||
|
"@wizardSaveAndContinue": {
|
||||||
|
"description": "Onboarding save and continue button label"
|
||||||
|
},
|
||||||
|
"wizardEnterDeviceName": "Enter a device name before continuing.",
|
||||||
|
"@wizardEnterDeviceName": {
|
||||||
|
"description": "Onboarding validation when device name is missing"
|
||||||
|
},
|
||||||
|
"wizardDeviceSetupSaved": "Saved {deviceName} with {presetName}.",
|
||||||
|
"@wizardDeviceSetupSaved": {
|
||||||
|
"description": "Onboarding success message after saving device setup",
|
||||||
|
"placeholders": {
|
||||||
|
"deviceName": {
|
||||||
|
"type": "String"
|
||||||
|
},
|
||||||
|
"presetName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wizardNetworkTitle": "Contacts, Rooms, and Repeaters",
|
||||||
|
"@wizardNetworkTitle": {
|
||||||
|
"description": "Updated onboarding network page title"
|
||||||
|
},
|
||||||
|
"wizardNetworkDescription": "The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.",
|
||||||
|
"@wizardNetworkDescription": {
|
||||||
|
"description": "Updated onboarding network page description"
|
||||||
|
},
|
||||||
|
"wizardNetworkFeature1": "Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.",
|
||||||
|
"@wizardNetworkFeature1": {
|
||||||
|
"description": "Updated onboarding network feature 1"
|
||||||
|
},
|
||||||
|
"wizardNetworkFeature2": "Use smart ping, room login, learned paths, and route reset tools when connectivity gets messy.",
|
||||||
|
"@wizardNetworkFeature2": {
|
||||||
|
"description": "Updated onboarding network feature 2"
|
||||||
|
},
|
||||||
|
"wizardNetworkFeature3": "Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.",
|
||||||
|
"@wizardNetworkFeature3": {
|
||||||
|
"description": "Updated onboarding network feature 3"
|
||||||
|
},
|
||||||
|
"wizardMapOpsTitle": "Map, Trails, and Shared Geometry",
|
||||||
|
"@wizardMapOpsTitle": {
|
||||||
|
"description": "Updated onboarding map page title"
|
||||||
|
},
|
||||||
|
"wizardMapOpsDescription": "The app map is tied directly into messaging, tracking, and SAR overlays instead of being a separate viewer.",
|
||||||
|
"@wizardMapOpsDescription": {
|
||||||
|
"description": "Updated onboarding map page description"
|
||||||
|
},
|
||||||
|
"wizardMapOpsFeature1": "Track your own position, teammate locations, and movement trails on the map.",
|
||||||
|
"@wizardMapOpsFeature1": {
|
||||||
|
"description": "Updated onboarding map feature 1"
|
||||||
|
},
|
||||||
|
"wizardMapOpsFeature2": "Open drawings from messages, preview them inline, and remove them from the map when needed.",
|
||||||
|
"@wizardMapOpsFeature2": {
|
||||||
|
"description": "Updated onboarding map feature 2"
|
||||||
|
},
|
||||||
|
"wizardMapOpsFeature3": "Use repeater map views and shared overlays to understand network reach in the field.",
|
||||||
|
"@wizardMapOpsFeature3": {
|
||||||
|
"description": "Updated onboarding map feature 3"
|
||||||
|
},
|
||||||
|
"wizardToolsTitle": "Tools Beyond Messaging",
|
||||||
|
"@wizardToolsTitle": {
|
||||||
|
"description": "Updated onboarding tools page title"
|
||||||
|
},
|
||||||
|
"wizardToolsDescription": "There is more here than the four main tabs. The app also includes configuration, diagnostics, and optional sensor workflows.",
|
||||||
|
"@wizardToolsDescription": {
|
||||||
|
"description": "Updated onboarding tools page description"
|
||||||
|
},
|
||||||
|
"wizardToolsFeature1": "Open device config to change radio settings, telemetry, TX power, and companion details.",
|
||||||
|
"@wizardToolsFeature1": {
|
||||||
|
"description": "Updated onboarding tools feature 1"
|
||||||
|
},
|
||||||
|
"wizardToolsFeature2": "Enable the Sensors tab when you want watched sensor dashboards and quick refresh actions.",
|
||||||
|
"@wizardToolsFeature2": {
|
||||||
|
"description": "Updated onboarding tools feature 2"
|
||||||
|
},
|
||||||
|
"wizardToolsFeature3": "Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.",
|
||||||
|
"@wizardToolsFeature3": {
|
||||||
|
"description": "Updated onboarding tools feature 3"
|
||||||
|
},
|
||||||
|
"postConnectDiscoveryTitle": "Discover repeaters now?",
|
||||||
|
"@postConnectDiscoveryTitle": {
|
||||||
|
"description": "Title for the prompt shown after a successful device connection"
|
||||||
|
},
|
||||||
|
"postConnectDiscoveryDescription": "Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.",
|
||||||
|
"@postConnectDiscoveryDescription": {
|
||||||
|
"description": "Body text for the prompt shown after a successful device connection"
|
||||||
|
},
|
||||||
|
"contactInSensors": "In Sensors",
|
||||||
|
"@contactInSensors": {
|
||||||
|
"description": "Contact actions label when already in sensors"
|
||||||
|
},
|
||||||
|
"contactAddToSensors": "Add to Sensors",
|
||||||
|
"@contactAddToSensors": {
|
||||||
|
"description": "Contact actions label to add to sensors"
|
||||||
|
},
|
||||||
|
"contactSetPath": "Set path",
|
||||||
|
"@contactSetPath": {
|
||||||
|
"description": "Contact actions label to set route path"
|
||||||
|
},
|
||||||
|
"contactAddedToSensors": "{contactName} added to Sensors",
|
||||||
|
"@contactAddedToSensors": {
|
||||||
|
"description": "Snackbar after adding contact to sensors",
|
||||||
|
"placeholders": {
|
||||||
|
"contactName": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contactFailedToClearRoute": "Failed to clear route: {error}",
|
||||||
|
"@contactFailedToClearRoute": {
|
||||||
|
"description": "Error when clearing a contact route fails",
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contactRouteCleared": "Route cleared",
|
||||||
|
"@contactRouteCleared": {
|
||||||
|
"description": "Snackbar after route is cleared"
|
||||||
|
},
|
||||||
|
"contactRouteSet": "Route set: {route}",
|
||||||
|
"@contactRouteSet": {
|
||||||
|
"description": "Snackbar after route is set",
|
||||||
|
"placeholders": {
|
||||||
|
"route": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"contactFailedToSetRoute": "Failed to set route: {error}",
|
||||||
|
"@contactFailedToSetRoute": {
|
||||||
|
"description": "Error when setting a contact route fails",
|
||||||
|
"placeholders": {
|
||||||
|
"error": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rssi": "RSSI",
|
||||||
|
"@rssi": {
|
||||||
|
"description": "Received signal strength indicator"
|
||||||
|
},
|
||||||
|
"snr": "SNR",
|
||||||
|
"@snr": {
|
||||||
|
"description": "Signal-to-noise ratio"
|
||||||
|
},
|
||||||
|
"ackTimeout": "ACK timeout",
|
||||||
|
"opcode": "Opcode",
|
||||||
|
"payload": "Payload",
|
||||||
|
"hops": "Hops",
|
||||||
|
"hashSize": "Hash size",
|
||||||
|
"pathBytes": "Path bytes",
|
||||||
|
"selectedPath": "Selected path",
|
||||||
|
"estimatedTx": "Estimated tx",
|
||||||
|
"senderToReceipt": "Sender to receipt",
|
||||||
|
"receivedCopies": "Received copies",
|
||||||
|
"retryCause": "Retry cause",
|
||||||
|
"retryMode": "Retry mode",
|
||||||
|
"retryResult": "Retry result",
|
||||||
|
"lastRetry": "Last retry",
|
||||||
|
"rxPackets": "RX packets",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Rate",
|
||||||
|
"window": "Window",
|
||||||
|
"posttxDelay": "Post-tx delay",
|
||||||
|
"bandpass": "Band-pass",
|
||||||
|
"bandpassFilterVoice": "Band-pass filter voice",
|
||||||
|
"frequency": "Frequency",
|
||||||
|
"australia": "Australia",
|
||||||
|
"australiaNarrow": "Australia (Narrow)",
|
||||||
|
"australiaQld": "Australia: QLD",
|
||||||
|
"australiaSaWa": "Australia: SA, WA",
|
||||||
|
"newZealand": "New Zealand",
|
||||||
|
"newZealandNarrow": "New Zealand (Narrow)",
|
||||||
|
"switzerland": "Switzerland",
|
||||||
|
"portugal433": "Portugal 433",
|
||||||
|
"portugal868": "Portugal 868",
|
||||||
|
"czechRepublicNarrow": "Czech Republic (Narrow)",
|
||||||
|
"eu433mhzLongRange": "EU 433MHz (Long Range)",
|
||||||
|
"euukDeprecated": "EU/UK (Deprecated)",
|
||||||
|
"euukNarrow": "EU/UK (Narrow)",
|
||||||
|
"usacanadaRecommended": "USA/Canada (Recommended)",
|
||||||
|
"vietnamDeprecated": "Vietnam (Deprecated)",
|
||||||
|
"vietnamNarrow": "Vietnam (Narrow)",
|
||||||
|
"active": "Active",
|
||||||
|
"addContact": "Add Contact",
|
||||||
|
"all": "All",
|
||||||
|
"autoResolve": "Auto resolve",
|
||||||
|
"clearAllLabel": "Clear all",
|
||||||
|
"clearRelays": "Clear relays",
|
||||||
|
"clearFilters": "Clear filters",
|
||||||
|
"clearRoute": "Clear Route",
|
||||||
|
"clearMessages": "Clear Messages",
|
||||||
|
"clearScale": "Clear scale",
|
||||||
|
"clearDiscoveries": "Clear discoveries",
|
||||||
|
"clearOnlineTraceDatabase": "Clear online trace database",
|
||||||
|
"clearAllChannels": "Clear all channels",
|
||||||
|
"clearAllContacts": "Clear all contacts",
|
||||||
|
"clearChannels": "Clear channels",
|
||||||
|
"clearContacts": "Clear contacts",
|
||||||
|
"clearPathOnMaxRetry": "Clear path on max retry",
|
||||||
|
"create": "Create",
|
||||||
|
"custom": "Custom",
|
||||||
|
"defaultValue": "Default",
|
||||||
|
"duplicate": "Duplicate",
|
||||||
|
"editName": "Edit name",
|
||||||
|
"open": "Open",
|
||||||
|
"paste": "Paste",
|
||||||
|
"preview": "Preview",
|
||||||
|
"remove": "Remove",
|
||||||
|
"rename": "Rename",
|
||||||
|
"resolveAll": "Resolve all",
|
||||||
|
"send": "Send",
|
||||||
|
"sendAnyway": "Send anyway",
|
||||||
|
"share": "Share",
|
||||||
|
"shareContact": "Share Contact",
|
||||||
|
"trace": "Trace",
|
||||||
|
"use": "Use",
|
||||||
|
"useSelectedFrequency": "Use selected frequency",
|
||||||
|
"discovery": "Discovery",
|
||||||
|
"discoverRepeaters": "Discover repeaters",
|
||||||
|
"discoverSensors": "Discover sensors",
|
||||||
|
"repeaterDiscoverySent": "Repeater discovery sent",
|
||||||
|
"sensorDiscoverySent": "Sensor discovery sent",
|
||||||
|
"clearedPendingDiscoveries": "Cleared pending discoveries.",
|
||||||
|
"autoDiscovery": "Auto discovery",
|
||||||
|
"enableAutomaticAdding": "Enable automatic adding",
|
||||||
|
"autoaddRepeaters": "Auto-add repeaters",
|
||||||
|
"autoaddRoomServers": "Auto-add room servers",
|
||||||
|
"autoaddSensors": "Auto-add sensors",
|
||||||
|
"autoaddUsers": "Auto-add users",
|
||||||
|
"overwriteOldestWhenFull": "Overwrite oldest when full",
|
||||||
|
"storage": "Storage",
|
||||||
|
"dangerZone": "Danger zone",
|
||||||
|
"profiles": "Profiles",
|
||||||
|
"favourites": "Favourites",
|
||||||
|
"sensors": "Sensors",
|
||||||
|
"others": "Others",
|
||||||
|
"gpsModule": "GPS Module",
|
||||||
|
"liveTraffic": "Live Traffic",
|
||||||
|
"repeatersMap": "Repeaters Map",
|
||||||
|
"spectrumScan": "Spectrum Scan",
|
||||||
|
"blePacketLogs": "BLE Packet Logs",
|
||||||
|
"onlineTraceDatabase": "Online trace database",
|
||||||
|
"routePathByteSize": "Route path byte size",
|
||||||
|
"messageNotifications": "Message notifications",
|
||||||
|
"sarAlerts": "SAR alerts",
|
||||||
|
"discoveryNotifications": "Discovery notifications",
|
||||||
|
"updateNotifications": "Update notifications",
|
||||||
|
"muteWhileAppIsOpen": "Mute while app is open",
|
||||||
|
"disableContacts": "Disable Contacts",
|
||||||
|
"enableSensorsTab": "Enable Sensors tab",
|
||||||
|
"enableProfiles": "Enable Profiles",
|
||||||
|
"autoRouteRotation": "Auto route rotation",
|
||||||
|
"nearestRepeaterFallback": "Nearest repeater fallback",
|
||||||
|
"deleteAllStoredMessageHistory": "Delete all stored message history",
|
||||||
|
"messageFontSize": "Message font size",
|
||||||
|
"rotateMapWithHeading": "Rotate map with heading",
|
||||||
|
"showMapDebugInfo": "Show map debug info",
|
||||||
|
"openMapInFullscreen": "Open map in fullscreen",
|
||||||
|
"showSarMarkersLabel": "Show SAR markers",
|
||||||
|
"displaySarMarkersOnTheMainMap": "Display SAR markers on the main map",
|
||||||
|
"showAllContactTrailsLabel": "Show all contact trails",
|
||||||
|
"hideRepeatersOnMap": "Hide repeaters on map",
|
||||||
|
"setMapScale": "Set map scale",
|
||||||
|
"customMapScaleSaved": "Custom map scale saved",
|
||||||
|
"voiceBitrate": "Voice bitrate",
|
||||||
|
"voiceCompressor": "Voice compressor",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Balances quiet and loud speech levels",
|
||||||
|
"voiceLimiter": "Voice limiter",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Prevents clipping peaks before encoding",
|
||||||
|
"micAutoGain": "Mic auto gain",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Lets the recorder adjust input level",
|
||||||
|
"echoCancellation": "Echo cancellation",
|
||||||
|
"noiseSuppression": "Noise suppression",
|
||||||
|
"trimSilenceInVoiceMessages": "Trim silence in voice messages",
|
||||||
|
"compressor": "Compressor",
|
||||||
|
"limiter": "Limiter",
|
||||||
|
"autoGain": "Auto gain",
|
||||||
|
"echoCancel": "Echo cancel",
|
||||||
|
"noiseSuppress": "Noise suppress",
|
||||||
|
"silenceTrim": "Silence trim",
|
||||||
|
"maxImageSize": "Max image size",
|
||||||
|
"imageCompression": "Image compression",
|
||||||
|
"grayscale": "Grayscale",
|
||||||
|
"ultraMode": "Ultra mode",
|
||||||
|
"fastPrivateGpsUpdates": "Fast private GPS updates",
|
||||||
|
"movementThreshold": "Movement threshold",
|
||||||
|
"fastGpsMovementThreshold": "Fast GPS movement threshold",
|
||||||
|
"fastGpsActiveuseInterval": "Fast GPS active-use interval",
|
||||||
|
"activeuseUpdateInterval": "Active-use update interval",
|
||||||
|
"repeatNearbyTraffic": "Repeat nearby traffic",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Relay through repeaters across the mesh",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Nearby only, without repeater flooding",
|
||||||
|
"multihop": "Multi-hop",
|
||||||
|
"createProfile": "Create Profile",
|
||||||
|
"renameProfile": "Rename Profile",
|
||||||
|
"newProfile": "New Profile",
|
||||||
|
"manageProfiles": "Manage profiles",
|
||||||
|
"enableProfilesToStartManagingThem": "Enable profiles to start managing them.",
|
||||||
|
"openMessage": "Open message",
|
||||||
|
"jumpToTheRelatedSarMessage": "Jump to the related SAR message",
|
||||||
|
"removeSarMarker": "Remove SAR marker",
|
||||||
|
"pleaseSelectADestinationToSendSarMarker": "Please select a destination to send SAR marker",
|
||||||
|
"sarMarkerBroadcastToPublicChannel": "SAR marker broadcast to public channel",
|
||||||
|
"sarMarkerSentToRoom": "SAR marker sent to room",
|
||||||
|
"loadFromGallery": "Load from gallery",
|
||||||
|
"replaceImage": "Replace image",
|
||||||
|
"selectFromGallery": "Select from gallery",
|
||||||
|
"team": "Team",
|
||||||
|
"found": "Found",
|
||||||
|
"staging": "Staging",
|
||||||
|
"object": "Object",
|
||||||
|
"quiet": "Quiet",
|
||||||
|
"moderate": "Moderate",
|
||||||
|
"busy": "Busy",
|
||||||
|
"spectrumScanReturnedNoCandidateFrequencies": "Spectrum scan returned no candidate frequencies",
|
||||||
|
"searchMessages": "Search messages",
|
||||||
|
"sendImageFromGallery": "Send image from gallery",
|
||||||
|
"takePhoto": "Take photo",
|
||||||
|
"dmOnly": "DM only",
|
||||||
|
"allMessages": "All messages",
|
||||||
|
"sendToPublicChannel": "Send to Public Channel?",
|
||||||
|
"selectMarkerTypeAndDestination": "Select marker type and destination",
|
||||||
|
"noDestinationsAvailableLabel": "No destinations available",
|
||||||
|
"image": "Image",
|
||||||
|
"format": "Format",
|
||||||
|
"dimensions": "Dimensions",
|
||||||
|
"segments": "Segments",
|
||||||
|
"transfers": "Transfers",
|
||||||
|
"downloadedBy": "Downloaded by",
|
||||||
|
"saveDiscoverySettings": "Save discovery settings",
|
||||||
|
"savePublicInfo": "Save public info",
|
||||||
|
"saveRadioSettings": "Save radio settings",
|
||||||
|
"savePath": "Save Path",
|
||||||
|
"wipeDeviceData": "Wipe device data",
|
||||||
|
"wipeDevice": "Wipe device",
|
||||||
|
"destructiveDeviceActions": "Destructive device actions.",
|
||||||
|
"chooseAPresetOrFinetuneCustomRadioSettings": "Choose a preset or fine-tune custom radio settings.",
|
||||||
|
"chooseTheNameAndLocationThisDeviceShares": "Choose the name and location this device shares.",
|
||||||
|
"availableSpaceOnThisDevice": "Available space on this device.",
|
||||||
|
"used": "Used",
|
||||||
|
"total": "Total",
|
||||||
|
"renameValue": "Rename value",
|
||||||
|
"customizeFields": "Customize fields",
|
||||||
|
"livePreview": "Live preview",
|
||||||
|
"refreshSchedule": "Refresh schedule",
|
||||||
|
"noResponse": "No response",
|
||||||
|
"refreshing": "Refreshing",
|
||||||
|
"unavailable": "Unavailable",
|
||||||
|
"pickARelayOrNodeToWatchInSensors": "Pick a relay or node to watch in Sensors.",
|
||||||
|
"publicKeyLabel": "Public key",
|
||||||
|
"alreadyInContacts": "Already in contacts",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Connect to a device before adding contacts",
|
||||||
|
"fromContacts": "From contacts",
|
||||||
|
"onlineOnly": "Online only",
|
||||||
|
"inBoth": "In both",
|
||||||
|
"source": "Source",
|
||||||
|
"manualRouteEdit": "Manual route edit",
|
||||||
|
"observedMeshRoute": "Observed mesh route",
|
||||||
|
"allMessagesCleared": "All messages cleared",
|
||||||
|
"onlineTraceDatabaseCleared": "Online trace database cleared",
|
||||||
|
"packetLogsCleared": "Packet logs cleared",
|
||||||
|
"hexDataCopiedToClipboard": "Hex data copied to clipboard",
|
||||||
|
"developerModeEnabled": "Developer mode enabled",
|
||||||
|
"developerModeDisabled": "Developer mode disabled",
|
||||||
|
"clipboardIsEmpty": "Clipboard is empty",
|
||||||
|
"contactImported": "Contact imported",
|
||||||
|
"contactLinkCopiedToClipboard": "Contact link copied to clipboard",
|
||||||
|
"failedToExportContact": "Failed to export contact",
|
||||||
|
"noLogsToExport": "No logs to export",
|
||||||
|
"exportAsCsv": "Export as CSV",
|
||||||
|
"exportAsText": "Export as Text",
|
||||||
|
"receivedRfc3339": "Received (RFC3339)",
|
||||||
|
"buildTime": "Build Time",
|
||||||
|
"downloadUrlNotAvailable": "Download URL not available",
|
||||||
|
"cannotOpenDownloadUrl": "Cannot open download URL",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "Update check is only available on Android",
|
||||||
|
"youAreRunningTheLatestVersion": "You are running the latest version",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "Update available but download URL not found",
|
||||||
|
"startTictactoe": "Start Tic-Tac-Toe",
|
||||||
|
"tictactoeUnavailable": "Tic-Tac-Toe unavailable",
|
||||||
|
"tictactoeOpponentUnknown": "Tic-Tac-Toe: opponent unknown",
|
||||||
|
"tictactoeWaitingForStart": "Tic-Tac-Toe: waiting for start",
|
||||||
|
"acceptsShareLinks": "Accepts share links",
|
||||||
|
"supportsRawHex": "Supports raw hex",
|
||||||
|
"clipboardfriendly": "Clipboard-friendly",
|
||||||
|
"captured": "Captured",
|
||||||
|
"size": "Size",
|
||||||
|
"noCustomChannelsToClear": "No custom channels to clear.",
|
||||||
|
"noDeviceContactsToClear": "No device contacts to clear.",
|
||||||
|
|
||||||
|
"setRegionScope": "Set region scope",
|
||||||
|
"regionScope": "Region scope",
|
||||||
|
"regionScopeNone": "None (global)",
|
||||||
|
"clearRegionScope": "Clear scope",
|
||||||
|
"regionScopeWarning": "Only repeaters allowing this region will forward.",
|
||||||
|
"discoverRegions": "Discover from repeaters",
|
||||||
|
"discoveringRegions": "Discovering regions...",
|
||||||
|
"enterRegionName": "Region name (e.g. auckland)",
|
||||||
|
"noRegionsFound": "No regions found on this repeater.",
|
||||||
|
"regionScopeSet": "Region scope set to {name}",
|
||||||
|
"@regionScopeSet": {
|
||||||
|
"placeholders": {
|
||||||
|
"name": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"regionScopeCleared": "Region scope cleared"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1586,5 +1586,304 @@
|
|||||||
"rawDump": "Volcado sin procesar",
|
"rawDump": "Volcado sin procesar",
|
||||||
"cannotRetryMissingRecipient": "No se puede reintentar: falta información del destinatario",
|
"cannotRetryMissingRecipient": "No se puede reintentar: falta información del destinatario",
|
||||||
"voiceUnavailable": "Voz no disponible en este momento",
|
"voiceUnavailable": "Voz no disponible en este momento",
|
||||||
"requestingVoice": "Solicitando voz"
|
"requestingVoice": "Solicitando voz",
|
||||||
|
"device": "dispositivo",
|
||||||
|
"change": "Cambiar",
|
||||||
|
"wizardOverviewDescription": "Esta aplicación reúne mensajería MeshCore, actualizaciones SAR de campo, mapas y herramientas del dispositivo en un solo lugar.",
|
||||||
|
"wizardOverviewFeature1": "Envía mensajes directos, publicaciones en salas y mensajes de canal desde la pestaña principal Mensajes.",
|
||||||
|
"wizardOverviewFeature2": "Comparte marcadores SAR, dibujos del mapa, clips de voz e imágenes a través de la malla.",
|
||||||
|
"wizardOverviewFeature3": "Conéctate por BLE o TCP y luego gestiona la radio complementaria desde la aplicación.",
|
||||||
|
"wizardMessagingTitle": "Mensajería e informes de campo",
|
||||||
|
"wizardMessagingDescription": "Aquí los mensajes son más que texto plano. La aplicación ya admite varias cargas operativas y flujos de transferencia.",
|
||||||
|
"wizardMessagingFeature1": "Envía mensajes directos, publicaciones en salas y tráfico de canal desde un solo editor.",
|
||||||
|
"wizardMessagingFeature2": "Crea actualizaciones SAR y plantillas SAR reutilizables para informes de campo habituales.",
|
||||||
|
"wizardMessagingFeature3": "Transfiere sesiones de voz e imágenes con progreso y estimaciones de tiempo de aire en la interfaz.",
|
||||||
|
"wizardConnectDeviceTitle": "Conectar dispositivo",
|
||||||
|
"wizardConnectDeviceDescription": "Conecta tu radio MeshCore, elige un nombre y aplica un ajuste predefinido antes de continuar.",
|
||||||
|
"wizardSetupBadge": "Configuración",
|
||||||
|
"wizardOverviewBadge": "Resumen",
|
||||||
|
"wizardConnectedToDevice": "Conectado a {deviceName}",
|
||||||
|
"wizardNoDeviceConnected": "Aún no hay ningún dispositivo conectado",
|
||||||
|
"wizardSkipForNow": "Omitir por ahora",
|
||||||
|
"wizardDeviceNameLabel": "Nombre del dispositivo",
|
||||||
|
"wizardDeviceNameHelp": "Este nombre se anuncia a otros usuarios de MeshCore.",
|
||||||
|
"wizardConfigRegionLabel": "Región de configuración",
|
||||||
|
"wizardConfigRegionHelp": "Usa la lista oficial completa de ajustes predefinidos de MeshCore. El valor predeterminado es EU/UK (Narrow).",
|
||||||
|
"wizardPresetNote1": "Asegúrate de que el ajuste seleccionado coincida con la normativa local de radio.",
|
||||||
|
"wizardPresetNote2": "La lista coincide con la fuente oficial de ajustes predefinidos de la herramienta de configuración de MeshCore.",
|
||||||
|
"wizardPresetNote3": "EU/UK (Narrow) sigue seleccionado por defecto durante la incorporación.",
|
||||||
|
"wizardSaving": "Guardando...",
|
||||||
|
"wizardSaveAndContinue": "Guardar y continuar",
|
||||||
|
"wizardEnterDeviceName": "Introduce un nombre de dispositivo antes de continuar.",
|
||||||
|
"wizardDeviceSetupSaved": "Se guardó {deviceName} con {presetName}.",
|
||||||
|
"wizardNetworkTitle": "Contactos, salas y repetidores",
|
||||||
|
"wizardNetworkDescription": "La pestaña Contactos organiza la red que descubres y las rutas que aprendes con el tiempo.",
|
||||||
|
"wizardNetworkFeature1": "Revisa miembros del equipo, repetidores, salas, canales y anuncios pendientes en una sola lista.",
|
||||||
|
"wizardNetworkFeature2": "Usa ping inteligente, acceso a salas, rutas aprendidas y herramientas de reinicio de ruta cuando la conectividad se complique.",
|
||||||
|
"wizardNetworkFeature3": "Crea canales y gestiona destinos de red sin salir de la aplicación.",
|
||||||
|
"wizardMapOpsTitle": "Mapa, rastros y geometría compartida",
|
||||||
|
"wizardMapOpsDescription": "El mapa de la aplicación está vinculado directamente a la mensajería, el seguimiento y las superposiciones SAR en lugar de ser un visor aparte.",
|
||||||
|
"wizardMapOpsFeature1": "Sigue tu propia posición, las ubicaciones de tus compañeros y los rastros de movimiento en el mapa.",
|
||||||
|
"wizardMapOpsFeature2": "Abre dibujos desde mensajes, prévisualos en línea y elimínalos del mapa cuando sea necesario.",
|
||||||
|
"wizardMapOpsFeature3": "Usa vistas de mapa de repetidores y superposiciones compartidas para comprender el alcance de la red sobre el terreno.",
|
||||||
|
"wizardToolsTitle": "Herramientas más allá de la mensajería",
|
||||||
|
"wizardToolsDescription": "Aquí hay más que las cuatro pestañas principales. La aplicación también incluye configuración, diagnóstico y flujos opcionales de sensores.",
|
||||||
|
"wizardToolsFeature1": "Abre la configuración del dispositivo para cambiar ajustes de radio, telemetría, potencia TX y detalles del equipo complementario.",
|
||||||
|
"wizardToolsFeature2": "Activa la pestaña Sensores cuando quieras paneles de sensores vigilados y acciones de actualización rápida.",
|
||||||
|
"wizardToolsFeature3": "Usa registros de paquetes, escaneo de espectro y diagnósticos de desarrollador al solucionar problemas de la malla.",
|
||||||
|
"contactInSensors": "En Sensores",
|
||||||
|
"contactAddToSensors": "Añadir a Sensores",
|
||||||
|
"contactSetPath": "Definir ruta",
|
||||||
|
"contactAddedToSensors": "{contactName} se añadió a Sensores",
|
||||||
|
"contactFailedToClearRoute": "No se pudo borrar la ruta: {error}",
|
||||||
|
"contactRouteCleared": "Ruta borrada",
|
||||||
|
"contactRouteSet": "Ruta establecida: {route}",
|
||||||
|
"contactFailedToSetRoute": "No se pudo establecer la ruta: {error}",
|
||||||
|
"rssi": "RSSI",
|
||||||
|
"snr": "SNR",
|
||||||
|
"ackTimeout": "Tiempo de espera ACK",
|
||||||
|
"opcode": "Código de operación",
|
||||||
|
"payload": "Carga útil",
|
||||||
|
"hops": "Saltos",
|
||||||
|
"hashSize": "Tamaño del hash",
|
||||||
|
"pathBytes": "Bytes de ruta",
|
||||||
|
"selectedPath": "Ruta seleccionada",
|
||||||
|
"estimatedTx": "Transmisión estimada",
|
||||||
|
"senderToReceipt": "Del remitente al recibo",
|
||||||
|
"receivedCopies": "Copias recibidas",
|
||||||
|
"retryCause": "Causa del reintento",
|
||||||
|
"retryMode": "Modo de reintento",
|
||||||
|
"retryResult": "Resultado del reintento",
|
||||||
|
"lastRetry": "Último reintento",
|
||||||
|
"rxPackets": "Paquetes RX",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Velocidad",
|
||||||
|
"window": "Ventana",
|
||||||
|
"posttxDelay": "Retardo post-tx",
|
||||||
|
"bandpass": "Paso de banda",
|
||||||
|
"bandpassFilterVoice": "Filtro de paso de banda de voz",
|
||||||
|
"frequency": "Frecuencia",
|
||||||
|
"australia": "Australia",
|
||||||
|
"australiaNarrow": "Australia (Estrecho)",
|
||||||
|
"australiaQld": "Australia: QLD",
|
||||||
|
"australiaSaWa": "Australia: SA, WA",
|
||||||
|
"newZealand": "Nueva Zelanda",
|
||||||
|
"newZealandNarrow": "Nueva Zelanda (Estrecho)",
|
||||||
|
"switzerland": "Suiza",
|
||||||
|
"portugal433": "Portugal 433",
|
||||||
|
"portugal868": "Portugal 868",
|
||||||
|
"czechRepublicNarrow": "República Checa (Estrecho)",
|
||||||
|
"eu433mhzLongRange": "EU 433MHz (Largo Alcance)",
|
||||||
|
"euukDeprecated": "UE/RU (Obsoleto)",
|
||||||
|
"euukNarrow": "UE/RU (Estrecho)",
|
||||||
|
"usacanadaRecommended": "EE.UU./Canadá (Recomendado)",
|
||||||
|
"vietnamDeprecated": "Vietnam (Obsoleto)",
|
||||||
|
"vietnamNarrow": "Vietnam (Estrecho)",
|
||||||
|
"active": "Activo",
|
||||||
|
"addContact": "Añadir contacto",
|
||||||
|
"all": "Todo",
|
||||||
|
"autoResolve": "Resolver automáticamente",
|
||||||
|
"clearAllLabel": "Borrar todo",
|
||||||
|
"clearRelays": "Borrar repetidores",
|
||||||
|
"clearFilters": "Borrar filtros",
|
||||||
|
"clearRoute": "Borrar ruta",
|
||||||
|
"clearMessages": "Borrar mensajes",
|
||||||
|
"clearScale": "Borrar escala",
|
||||||
|
"clearDiscoveries": "Borrar descubrimientos",
|
||||||
|
"clearOnlineTraceDatabase": "Borrar base de datos de trazas",
|
||||||
|
"clearAllChannels": "Borrar todos los canales",
|
||||||
|
"clearAllContacts": "Borrar todos los contactos",
|
||||||
|
"clearChannels": "Borrar canales",
|
||||||
|
"clearContacts": "Borrar contactos",
|
||||||
|
"clearPathOnMaxRetry": "Borrar ruta en máximo reintento",
|
||||||
|
"create": "Crear",
|
||||||
|
"custom": "Personalizado",
|
||||||
|
"defaultValue": "Predeterminado",
|
||||||
|
"duplicate": "Duplicar",
|
||||||
|
"editName": "Editar nombre",
|
||||||
|
"open": "Abrir",
|
||||||
|
"paste": "Pegar",
|
||||||
|
"preview": "Vista previa",
|
||||||
|
"remove": "Eliminar",
|
||||||
|
"rename": "Renombrar",
|
||||||
|
"resolveAll": "Resolver todo",
|
||||||
|
"send": "Enviar",
|
||||||
|
"sendAnyway": "Enviar de todos modos",
|
||||||
|
"share": "Compartir",
|
||||||
|
"shareContact": "Compartir contacto",
|
||||||
|
"trace": "Traza",
|
||||||
|
"use": "Usar",
|
||||||
|
"useSelectedFrequency": "Usar frecuencia seleccionada",
|
||||||
|
"discovery": "Descubrimiento",
|
||||||
|
"discoverRepeaters": "Descubrir repetidores",
|
||||||
|
"discoverSensors": "Descubrir sensores",
|
||||||
|
"repeaterDiscoverySent": "Descubrimiento de repetidores enviado",
|
||||||
|
"sensorDiscoverySent": "Descubrimiento de sensores enviado",
|
||||||
|
"clearedPendingDiscoveries": "Descubrimientos pendientes borrados.",
|
||||||
|
"autoDiscovery": "Descubrimiento automático",
|
||||||
|
"enableAutomaticAdding": "Habilitar adición automática",
|
||||||
|
"autoaddRepeaters": "Añadir repetidores automáticamente",
|
||||||
|
"autoaddRoomServers": "Añadir servidores de sala automáticamente",
|
||||||
|
"autoaddSensors": "Añadir sensores automáticamente",
|
||||||
|
"autoaddUsers": "Añadir usuarios automáticamente",
|
||||||
|
"overwriteOldestWhenFull": "Sobrescribir más antiguos cuando esté lleno",
|
||||||
|
"storage": "Almacenamiento",
|
||||||
|
"dangerZone": "Zona de peligro",
|
||||||
|
"profiles": "Perfiles",
|
||||||
|
"favourites": "Favoritos",
|
||||||
|
"sensors": "Sensores",
|
||||||
|
"others": "Otros",
|
||||||
|
"gpsModule": "Módulo GPS",
|
||||||
|
"liveTraffic": "Tráfico en vivo",
|
||||||
|
"repeatersMap": "Mapa de repetidores",
|
||||||
|
"spectrumScan": "Escaneo de espectro",
|
||||||
|
"blePacketLogs": "Registros de paquetes BLE",
|
||||||
|
"onlineTraceDatabase": "Base de datos de trazas",
|
||||||
|
"routePathByteSize": "Tamaño en bytes de la ruta",
|
||||||
|
"messageNotifications": "Notificaciones de mensajes",
|
||||||
|
"sarAlerts": "Alertas SAR",
|
||||||
|
"discoveryNotifications": "Notificaciones de descubrimiento",
|
||||||
|
"updateNotifications": "Notificaciones de actualización",
|
||||||
|
"muteWhileAppIsOpen": "Silenciar con la app abierta",
|
||||||
|
"disableContacts": "Desactivar contactos",
|
||||||
|
"enableSensorsTab": "Habilitar pestaña Sensores",
|
||||||
|
"enableProfiles": "Habilitar perfiles",
|
||||||
|
"autoRouteRotation": "Rotación automática de ruta",
|
||||||
|
"nearestRepeaterFallback": "Repetidor más cercano como reserva",
|
||||||
|
"deleteAllStoredMessageHistory": "Eliminar todo el historial de mensajes",
|
||||||
|
"messageFontSize": "Tamaño de fuente de mensajes",
|
||||||
|
"rotateMapWithHeading": "Rotar mapa con rumbo",
|
||||||
|
"showMapDebugInfo": "Mostrar info de depuración del mapa",
|
||||||
|
"openMapInFullscreen": "Abrir mapa en pantalla completa",
|
||||||
|
"showSarMarkersLabel": "Mostrar marcadores SAR",
|
||||||
|
"displaySarMarkersOnTheMainMap": "Mostrar marcadores SAR en el mapa principal",
|
||||||
|
"showAllContactTrailsLabel": "Mostrar todos los rastros de contactos",
|
||||||
|
"hideRepeatersOnMap": "Ocultar repetidores en el mapa",
|
||||||
|
"setMapScale": "Establecer escala del mapa",
|
||||||
|
"customMapScaleSaved": "Escala de mapa personalizada guardada",
|
||||||
|
"voiceBitrate": "Tasa de bits de voz",
|
||||||
|
"voiceCompressor": "Compresor de voz",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Equilibra niveles de habla suaves y altos",
|
||||||
|
"voiceLimiter": "Limitador de voz",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Previene recorte de picos antes de codificar",
|
||||||
|
"micAutoGain": "Ganancia automática del micrófono",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Permite al grabador ajustar el nivel de entrada",
|
||||||
|
"echoCancellation": "Cancelación de eco",
|
||||||
|
"noiseSuppression": "Supresión de ruido",
|
||||||
|
"trimSilenceInVoiceMessages": "Recortar silencio en mensajes de voz",
|
||||||
|
"compressor": "Compresor",
|
||||||
|
"limiter": "Limitador",
|
||||||
|
"autoGain": "Ganancia automática",
|
||||||
|
"echoCancel": "Eco",
|
||||||
|
"noiseSuppress": "Ruido",
|
||||||
|
"silenceTrim": "Silencio",
|
||||||
|
"maxImageSize": "Tamaño máximo de imagen",
|
||||||
|
"imageCompression": "Compresión de imagen",
|
||||||
|
"grayscale": "Escala de grises",
|
||||||
|
"ultraMode": "Modo ultra",
|
||||||
|
"fastPrivateGpsUpdates": "Actualizaciones GPS privadas rápidas",
|
||||||
|
"movementThreshold": "Umbral de movimiento",
|
||||||
|
"fastGpsMovementThreshold": "Umbral de movimiento GPS rápido",
|
||||||
|
"fastGpsActiveuseInterval": "Intervalo de uso activo GPS rápido",
|
||||||
|
"activeuseUpdateInterval": "Intervalo de actualización de uso activo",
|
||||||
|
"repeatNearbyTraffic": "Repetir tráfico cercano",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Retransmitir a través de repetidores en la red",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Solo cercanos, sin inundación de repetidores",
|
||||||
|
"multihop": "Multi-salto",
|
||||||
|
"createProfile": "Crear perfil",
|
||||||
|
"renameProfile": "Renombrar perfil",
|
||||||
|
"newProfile": "Nuevo perfil",
|
||||||
|
"manageProfiles": "Gestionar perfiles",
|
||||||
|
"enableProfilesToStartManagingThem": "Habilite los perfiles para comenzar a gestionarlos.",
|
||||||
|
"openMessage": "Abrir mensaje",
|
||||||
|
"jumpToTheRelatedSarMessage": "Ir al mensaje SAR relacionado",
|
||||||
|
"removeSarMarker": "Eliminar marcador SAR",
|
||||||
|
"pleaseSelectADestinationToSendSarMarker": "Seleccione un destino para enviar el marcador SAR",
|
||||||
|
"sarMarkerBroadcastToPublicChannel": "Marcador SAR transmitido al canal público",
|
||||||
|
"sarMarkerSentToRoom": "Marcador SAR enviado a la sala",
|
||||||
|
"loadFromGallery": "Cargar desde galería",
|
||||||
|
"replaceImage": "Reemplazar imagen",
|
||||||
|
"selectFromGallery": "Seleccionar de galería",
|
||||||
|
"team": "Equipo",
|
||||||
|
"found": "Encontrado",
|
||||||
|
"staging": "Área de reunión",
|
||||||
|
"object": "Objeto",
|
||||||
|
"quiet": "Silencioso",
|
||||||
|
"moderate": "Moderado",
|
||||||
|
"busy": "Ocupado",
|
||||||
|
"spectrumScanReturnedNoCandidateFrequencies": "El escaneo de espectro no encontró frecuencias candidatas",
|
||||||
|
"searchMessages": "Buscar mensajes",
|
||||||
|
"sendImageFromGallery": "Enviar imagen desde galería",
|
||||||
|
"takePhoto": "Tomar foto",
|
||||||
|
"dmOnly": "Solo mensaje directo",
|
||||||
|
"allMessages": "Todos los mensajes",
|
||||||
|
"sendToPublicChannel": "¿Enviar al canal público?",
|
||||||
|
"selectMarkerTypeAndDestination": "Seleccione tipo de marcador y destino",
|
||||||
|
"noDestinationsAvailableLabel": "No hay destinos disponibles",
|
||||||
|
"image": "Imagen",
|
||||||
|
"format": "Formato",
|
||||||
|
"dimensions": "Dimensiones",
|
||||||
|
"segments": "Segmentos",
|
||||||
|
"transfers": "Transferencias",
|
||||||
|
"downloadedBy": "Descargado por",
|
||||||
|
"saveDiscoverySettings": "Guardar ajustes de descubrimiento",
|
||||||
|
"savePublicInfo": "Guardar información pública",
|
||||||
|
"saveRadioSettings": "Guardar ajustes de radio",
|
||||||
|
"savePath": "Guardar ruta",
|
||||||
|
"wipeDeviceData": "Borrar datos del dispositivo",
|
||||||
|
"wipeDevice": "Borrar dispositivo",
|
||||||
|
"destructiveDeviceActions": "Acciones destructivas del dispositivo.",
|
||||||
|
"chooseAPresetOrFinetuneCustomRadioSettings": "Elija un preajuste o ajuste la configuración de radio.",
|
||||||
|
"chooseTheNameAndLocationThisDeviceShares": "Elija el nombre y la ubicación que comparte este dispositivo.",
|
||||||
|
"availableSpaceOnThisDevice": "Espacio disponible en este dispositivo.",
|
||||||
|
"used": "Usado",
|
||||||
|
"total": "Total",
|
||||||
|
"renameValue": "Renombrar valor",
|
||||||
|
"customizeFields": "Personalizar campos",
|
||||||
|
"livePreview": "Vista previa en vivo",
|
||||||
|
"refreshSchedule": "Programación de actualización",
|
||||||
|
"noResponse": "Sin respuesta",
|
||||||
|
"refreshing": "Actualizando",
|
||||||
|
"unavailable": "No disponible",
|
||||||
|
"pickARelayOrNodeToWatchInSensors": "Elija un relay o nodo para observar.",
|
||||||
|
"publicKeyLabel": "Clave pública",
|
||||||
|
"alreadyInContacts": "Ya en contactos",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Conéctese a un dispositivo antes de añadir contactos",
|
||||||
|
"fromContacts": "De contactos",
|
||||||
|
"onlineOnly": "Solo en línea",
|
||||||
|
"inBoth": "En ambos",
|
||||||
|
"source": "Fuente",
|
||||||
|
"manualRouteEdit": "Edición manual de ruta",
|
||||||
|
"observedMeshRoute": "Ruta mesh observada",
|
||||||
|
"allMessagesCleared": "Todos los mensajes borrados",
|
||||||
|
"onlineTraceDatabaseCleared": "Base de datos de trazas borrada",
|
||||||
|
"packetLogsCleared": "Registros de paquetes borrados",
|
||||||
|
"hexDataCopiedToClipboard": "Datos hex copiados al portapapeles",
|
||||||
|
"developerModeEnabled": "Modo desarrollador activado",
|
||||||
|
"developerModeDisabled": "Modo desarrollador desactivado",
|
||||||
|
"clipboardIsEmpty": "El portapapeles está vacío",
|
||||||
|
"contactImported": "Contacto importado",
|
||||||
|
"contactLinkCopiedToClipboard": "Enlace de contacto copiado al portapapeles",
|
||||||
|
"failedToExportContact": "Error al exportar el contacto",
|
||||||
|
"noLogsToExport": "No hay registros para exportar",
|
||||||
|
"exportAsCsv": "Exportar como CSV",
|
||||||
|
"exportAsText": "Exportar como texto",
|
||||||
|
"receivedRfc3339": "Recibido (RFC3339)",
|
||||||
|
"buildTime": "Hora de compilación",
|
||||||
|
"downloadUrlNotAvailable": "URL de descarga no disponible",
|
||||||
|
"cannotOpenDownloadUrl": "No se puede abrir la URL de descarga",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "La comprobación de actualizaciones solo está disponible en Android",
|
||||||
|
"youAreRunningTheLatestVersion": "Está ejecutando la última versión",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "Actualización disponible pero URL de descarga no encontrada",
|
||||||
|
"startTictactoe": "Iniciar Tic-Tac-Toe",
|
||||||
|
"tictactoeUnavailable": "Tic-Tac-Toe no disponible",
|
||||||
|
"tictactoeOpponentUnknown": "Tic-Tac-Toe: oponente desconocido",
|
||||||
|
"tictactoeWaitingForStart": "Tic-Tac-Toe: esperando inicio",
|
||||||
|
"acceptsShareLinks": "Acepta enlaces compartidos",
|
||||||
|
"supportsRawHex": "Soporta hex sin procesar",
|
||||||
|
"clipboardfriendly": "Compatible con portapapeles",
|
||||||
|
"captured": "Capturado",
|
||||||
|
"size": "Tamaño",
|
||||||
|
"noCustomChannelsToClear": "No hay canales personalizados que borrar.",
|
||||||
|
"noDeviceContactsToClear": "No hay contactos del dispositivo que borrar."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1589,5 +1589,304 @@
|
|||||||
"rawDump": "Vidage brut",
|
"rawDump": "Vidage brut",
|
||||||
"cannotRetryMissingRecipient": "Impossible de réessayer : informations du destinataire manquantes",
|
"cannotRetryMissingRecipient": "Impossible de réessayer : informations du destinataire manquantes",
|
||||||
"voiceUnavailable": "Voix indisponible pour le moment",
|
"voiceUnavailable": "Voix indisponible pour le moment",
|
||||||
"requestingVoice": "Demande de voix"
|
"requestingVoice": "Demande de voix",
|
||||||
|
"device": "appareil",
|
||||||
|
"change": "Modifier",
|
||||||
|
"wizardOverviewDescription": "Cette application réunit la messagerie MeshCore, les mises à jour SAR sur le terrain, la cartographie et les outils de l'appareil au même endroit.",
|
||||||
|
"wizardOverviewFeature1": "Envoyez des messages directs, des publications de salon et des messages de canal depuis l'onglet principal Messages.",
|
||||||
|
"wizardOverviewFeature2": "Partagez des marqueurs SAR, des dessins de carte, des extraits vocaux et des images sur le réseau maillé.",
|
||||||
|
"wizardOverviewFeature3": "Connectez-vous en BLE ou en TCP, puis gérez la radio compagnon directement dans l'application.",
|
||||||
|
"wizardMessagingTitle": "Messagerie et rapports de terrain",
|
||||||
|
"wizardMessagingDescription": "Ici, les messages sont plus que du texte brut. L'application prend déjà en charge plusieurs charges opérationnelles et flux de transfert.",
|
||||||
|
"wizardMessagingFeature1": "Envoyez des messages directs, des publications de salon et du trafic de canal depuis un seul compositeur.",
|
||||||
|
"wizardMessagingFeature2": "Créez des mises à jour SAR et des modèles SAR réutilisables pour les rapports de terrain courants.",
|
||||||
|
"wizardMessagingFeature3": "Transférez des sessions vocales et des images avec la progression et les estimations de temps d'antenne dans l'interface.",
|
||||||
|
"wizardConnectDeviceTitle": "Connecter l'appareil",
|
||||||
|
"wizardConnectDeviceDescription": "Connectez votre radio MeshCore, choisissez un nom et appliquez un préréglage radio avant de continuer.",
|
||||||
|
"wizardSetupBadge": "Configuration",
|
||||||
|
"wizardOverviewBadge": "Aperçu",
|
||||||
|
"wizardConnectedToDevice": "Connecté à {deviceName}",
|
||||||
|
"wizardNoDeviceConnected": "Aucun appareil connecté pour le moment",
|
||||||
|
"wizardSkipForNow": "Ignorer pour l'instant",
|
||||||
|
"wizardDeviceNameLabel": "Nom de l'appareil",
|
||||||
|
"wizardDeviceNameHelp": "Ce nom est annoncé aux autres utilisateurs de MeshCore.",
|
||||||
|
"wizardConfigRegionLabel": "Région de configuration",
|
||||||
|
"wizardConfigRegionHelp": "Utilise la liste officielle complète des préréglages MeshCore. La valeur par défaut est EU/UK (Narrow).",
|
||||||
|
"wizardPresetNote1": "Assurez-vous que le préréglage sélectionné correspond à votre réglementation radio locale.",
|
||||||
|
"wizardPresetNote2": "La liste correspond au flux officiel des préréglages de l'outil de configuration MeshCore.",
|
||||||
|
"wizardPresetNote3": "EU/UK (Narrow) reste sélectionné par défaut pendant l'onboarding.",
|
||||||
|
"wizardSaving": "Enregistrement...",
|
||||||
|
"wizardSaveAndContinue": "Enregistrer et continuer",
|
||||||
|
"wizardEnterDeviceName": "Saisissez un nom d'appareil avant de continuer.",
|
||||||
|
"wizardDeviceSetupSaved": "{deviceName} enregistré avec {presetName}.",
|
||||||
|
"wizardNetworkTitle": "Contacts, salons et répéteurs",
|
||||||
|
"wizardNetworkDescription": "L'onglet Contacts organise le réseau que vous découvrez et les routes apprises au fil du temps.",
|
||||||
|
"wizardNetworkFeature1": "Consultez les membres de l'équipe, les répéteurs, les salons, les canaux et les annonces en attente dans une seule liste.",
|
||||||
|
"wizardNetworkFeature2": "Utilisez le smart ping, la connexion aux salons, les chemins appris et les outils de réinitialisation des routes lorsque la connectivité devient chaotique.",
|
||||||
|
"wizardNetworkFeature3": "Créez des canaux et gérez les destinations réseau sans quitter l'application.",
|
||||||
|
"wizardMapOpsTitle": "Carte, traces et géométrie partagée",
|
||||||
|
"wizardMapOpsDescription": "La carte de l'application est directement liée à la messagerie, au suivi et aux couches SAR au lieu d'être un simple visualiseur séparé.",
|
||||||
|
"wizardMapOpsFeature1": "Suivez votre position, celles de vos coéquipiers et les traces de déplacement sur la carte.",
|
||||||
|
"wizardMapOpsFeature2": "Ouvrez les dessins depuis les messages, prévisualisez-les en ligne et supprimez-les de la carte si nécessaire.",
|
||||||
|
"wizardMapOpsFeature3": "Utilisez les vues de carte des répéteurs et les couches partagées pour comprendre la portée du réseau sur le terrain.",
|
||||||
|
"wizardToolsTitle": "Outils au-delà de la messagerie",
|
||||||
|
"wizardToolsDescription": "Il y a plus ici que les quatre onglets principaux. L'application inclut aussi la configuration, le diagnostic et des workflows de capteurs optionnels.",
|
||||||
|
"wizardToolsFeature1": "Ouvrez la configuration de l'appareil pour modifier les réglages radio, la télémétrie, la puissance TX et les détails du compagnon.",
|
||||||
|
"wizardToolsFeature2": "Activez l'onglet Capteurs lorsque vous voulez des tableaux de bord surveillés et des actions d'actualisation rapide.",
|
||||||
|
"wizardToolsFeature3": "Utilisez les journaux de paquets, le balayage du spectre et les diagnostics développeur pour dépanner le réseau maillé.",
|
||||||
|
"contactInSensors": "Dans Capteurs",
|
||||||
|
"contactAddToSensors": "Ajouter aux capteurs",
|
||||||
|
"contactSetPath": "Définir le chemin",
|
||||||
|
"contactAddedToSensors": "{contactName} ajouté aux capteurs",
|
||||||
|
"contactFailedToClearRoute": "Échec de l'effacement de la route : {error}",
|
||||||
|
"contactRouteCleared": "Route effacée",
|
||||||
|
"contactRouteSet": "Route définie : {route}",
|
||||||
|
"contactFailedToSetRoute": "Échec du réglage de la route : {error}",
|
||||||
|
"rssi": "RSSI",
|
||||||
|
"snr": "SNR",
|
||||||
|
"ackTimeout": "Délai ACK expiré",
|
||||||
|
"opcode": "Opcode",
|
||||||
|
"payload": "Charge utile",
|
||||||
|
"hops": "Sauts",
|
||||||
|
"hashSize": "Taille du hash",
|
||||||
|
"pathBytes": "Octets du chemin",
|
||||||
|
"selectedPath": "Chemin sélectionné",
|
||||||
|
"estimatedTx": "Émission estimée",
|
||||||
|
"senderToReceipt": "Émetteur au reçu",
|
||||||
|
"receivedCopies": "Copies reçues",
|
||||||
|
"retryCause": "Cause de la relance",
|
||||||
|
"retryMode": "Mode de relance",
|
||||||
|
"retryResult": "Résultat de la relance",
|
||||||
|
"lastRetry": "Dernière relance",
|
||||||
|
"rxPackets": "Paquets RX",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Débit",
|
||||||
|
"window": "Fenêtre",
|
||||||
|
"posttxDelay": "Délai post-émission",
|
||||||
|
"bandpass": "Passe-bande",
|
||||||
|
"bandpassFilterVoice": "Filtre passe-bande voix",
|
||||||
|
"frequency": "Fréquence",
|
||||||
|
"australia": "Australie",
|
||||||
|
"australiaNarrow": "Australie (Étroit)",
|
||||||
|
"australiaQld": "Australie: QLD",
|
||||||
|
"australiaSaWa": "Australie: SA, WA",
|
||||||
|
"newZealand": "Nouvelle-Zélande",
|
||||||
|
"newZealandNarrow": "Nouvelle-Zélande (Étroit)",
|
||||||
|
"switzerland": "Suisse",
|
||||||
|
"portugal433": "Portugal 433",
|
||||||
|
"portugal868": "Portugal 868",
|
||||||
|
"czechRepublicNarrow": "République tchèque (Étroit)",
|
||||||
|
"eu433mhzLongRange": "UE 433MHz (Longue Portée)",
|
||||||
|
"euukDeprecated": "UE/RU (Obsolète)",
|
||||||
|
"euukNarrow": "UE/RU (Étroit)",
|
||||||
|
"usacanadaRecommended": "USA/Canada (Recommandé)",
|
||||||
|
"vietnamDeprecated": "Vietnam (Obsolète)",
|
||||||
|
"vietnamNarrow": "Vietnam (Étroit)",
|
||||||
|
"active": "Actif",
|
||||||
|
"addContact": "Ajouter un contact",
|
||||||
|
"all": "Tout",
|
||||||
|
"autoResolve": "Résolution automatique",
|
||||||
|
"clearAllLabel": "Tout effacer",
|
||||||
|
"clearRelays": "Effacer les relais",
|
||||||
|
"clearFilters": "Effacer les filtres",
|
||||||
|
"clearRoute": "Effacer la route",
|
||||||
|
"clearMessages": "Effacer les messages",
|
||||||
|
"clearScale": "Effacer l'échelle",
|
||||||
|
"clearDiscoveries": "Effacer les découvertes",
|
||||||
|
"clearOnlineTraceDatabase": "Effacer la base de traces",
|
||||||
|
"clearAllChannels": "Effacer tous les canaux",
|
||||||
|
"clearAllContacts": "Effacer tous les contacts",
|
||||||
|
"clearChannels": "Effacer les canaux",
|
||||||
|
"clearContacts": "Effacer les contacts",
|
||||||
|
"clearPathOnMaxRetry": "Effacer le chemin au max de relances",
|
||||||
|
"create": "Créer",
|
||||||
|
"custom": "Personnalisé",
|
||||||
|
"defaultValue": "Par défaut",
|
||||||
|
"duplicate": "Dupliquer",
|
||||||
|
"editName": "Modifier le nom",
|
||||||
|
"open": "Ouvrir",
|
||||||
|
"paste": "Coller",
|
||||||
|
"preview": "Aperçu",
|
||||||
|
"remove": "Supprimer",
|
||||||
|
"rename": "Renommer",
|
||||||
|
"resolveAll": "Résoudre tout",
|
||||||
|
"send": "Envoyer",
|
||||||
|
"sendAnyway": "Envoyer quand même",
|
||||||
|
"share": "Partager",
|
||||||
|
"shareContact": "Partager le contact",
|
||||||
|
"trace": "Trace",
|
||||||
|
"use": "Utiliser",
|
||||||
|
"useSelectedFrequency": "Utiliser la fréquence sélectionnée",
|
||||||
|
"discovery": "Découverte",
|
||||||
|
"discoverRepeaters": "Découvrir les répéteurs",
|
||||||
|
"discoverSensors": "Découvrir les capteurs",
|
||||||
|
"repeaterDiscoverySent": "Découverte de répéteurs envoyée",
|
||||||
|
"sensorDiscoverySent": "Découverte de capteurs envoyée",
|
||||||
|
"clearedPendingDiscoveries": "Découvertes en attente effacées.",
|
||||||
|
"autoDiscovery": "Découverte automatique",
|
||||||
|
"enableAutomaticAdding": "Activer l'ajout automatique",
|
||||||
|
"autoaddRepeaters": "Ajout auto des répéteurs",
|
||||||
|
"autoaddRoomServers": "Ajout auto des serveurs de salle",
|
||||||
|
"autoaddSensors": "Ajout auto des capteurs",
|
||||||
|
"autoaddUsers": "Ajout auto des utilisateurs",
|
||||||
|
"overwriteOldestWhenFull": "Écraser les plus anciens si plein",
|
||||||
|
"storage": "Stockage",
|
||||||
|
"dangerZone": "Zone de danger",
|
||||||
|
"profiles": "Profils",
|
||||||
|
"favourites": "Favoris",
|
||||||
|
"sensors": "Capteurs",
|
||||||
|
"others": "Autres",
|
||||||
|
"gpsModule": "Module GPS",
|
||||||
|
"liveTraffic": "Trafic en direct",
|
||||||
|
"repeatersMap": "Carte des répéteurs",
|
||||||
|
"spectrumScan": "Scan spectral",
|
||||||
|
"blePacketLogs": "Journaux de paquets BLE",
|
||||||
|
"onlineTraceDatabase": "Base de données de traces",
|
||||||
|
"routePathByteSize": "Taille en octets du chemin",
|
||||||
|
"messageNotifications": "Notifications de messages",
|
||||||
|
"sarAlerts": "Alertes SAR",
|
||||||
|
"discoveryNotifications": "Notifications de découverte",
|
||||||
|
"updateNotifications": "Notifications de mise à jour",
|
||||||
|
"muteWhileAppIsOpen": "Muet quand l'appli est ouverte",
|
||||||
|
"disableContacts": "Désactiver les contacts",
|
||||||
|
"enableSensorsTab": "Activer l'onglet Capteurs",
|
||||||
|
"enableProfiles": "Activer les profils",
|
||||||
|
"autoRouteRotation": "Rotation automatique de route",
|
||||||
|
"nearestRepeaterFallback": "Répéteur le plus proche en repli",
|
||||||
|
"deleteAllStoredMessageHistory": "Supprimer tout l'historique",
|
||||||
|
"messageFontSize": "Taille de police des messages",
|
||||||
|
"rotateMapWithHeading": "Tourner la carte avec le cap",
|
||||||
|
"showMapDebugInfo": "Afficher les infos de débogage",
|
||||||
|
"openMapInFullscreen": "Ouvrir la carte en plein écran",
|
||||||
|
"showSarMarkersLabel": "Afficher les marqueurs SAR",
|
||||||
|
"displaySarMarkersOnTheMainMap": "Afficher les marqueurs SAR sur la carte",
|
||||||
|
"showAllContactTrailsLabel": "Afficher toutes les traces de contacts",
|
||||||
|
"hideRepeatersOnMap": "Masquer les répéteurs sur la carte",
|
||||||
|
"setMapScale": "Définir l'échelle de la carte",
|
||||||
|
"customMapScaleSaved": "Échelle de carte personnalisée enregistrée",
|
||||||
|
"voiceBitrate": "Débit vocal",
|
||||||
|
"voiceCompressor": "Compresseur vocal",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Équilibre les niveaux faibles et forts",
|
||||||
|
"voiceLimiter": "Limiteur vocal",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Empêche l'écrêtage avant l'encodage",
|
||||||
|
"micAutoGain": "Gain auto du micro",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Laisse l'enregistreur ajuster le niveau d'entrée",
|
||||||
|
"echoCancellation": "Annulation d'écho",
|
||||||
|
"noiseSuppression": "Suppression du bruit",
|
||||||
|
"trimSilenceInVoiceMessages": "Couper le silence dans les messages vocaux",
|
||||||
|
"compressor": "Compresseur",
|
||||||
|
"limiter": "Limiteur",
|
||||||
|
"autoGain": "Gain automatique",
|
||||||
|
"echoCancel": "Écho",
|
||||||
|
"noiseSuppress": "Bruit",
|
||||||
|
"silenceTrim": "Silence",
|
||||||
|
"maxImageSize": "Taille d'image maximale",
|
||||||
|
"imageCompression": "Compression d'image",
|
||||||
|
"grayscale": "Niveaux de gris",
|
||||||
|
"ultraMode": "Mode ultra",
|
||||||
|
"fastPrivateGpsUpdates": "Mises à jour GPS privées rapides",
|
||||||
|
"movementThreshold": "Seuil de mouvement",
|
||||||
|
"fastGpsMovementThreshold": "Seuil de mouvement GPS rapide",
|
||||||
|
"fastGpsActiveuseInterval": "Intervalle d'utilisation active GPS rapide",
|
||||||
|
"activeuseUpdateInterval": "Intervalle de mise à jour en utilisation active",
|
||||||
|
"repeatNearbyTraffic": "Répéter le trafic proche",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Relayer via les répéteurs du réseau",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Proximité seulement, sans inondation",
|
||||||
|
"multihop": "Multi-saut",
|
||||||
|
"createProfile": "Créer un profil",
|
||||||
|
"renameProfile": "Renommer le profil",
|
||||||
|
"newProfile": "Nouveau profil",
|
||||||
|
"manageProfiles": "Gérer les profils",
|
||||||
|
"enableProfilesToStartManagingThem": "Activez les profils pour commencer à les gérer.",
|
||||||
|
"openMessage": "Ouvrir le message",
|
||||||
|
"jumpToTheRelatedSarMessage": "Aller au message SAR associé",
|
||||||
|
"removeSarMarker": "Supprimer le marqueur SAR",
|
||||||
|
"pleaseSelectADestinationToSendSarMarker": "Sélectionnez une destination pour envoyer le marqueur",
|
||||||
|
"sarMarkerBroadcastToPublicChannel": "Marqueur SAR diffusé sur le canal public",
|
||||||
|
"sarMarkerSentToRoom": "Marqueur SAR envoyé à la salle",
|
||||||
|
"loadFromGallery": "Charger depuis la galerie",
|
||||||
|
"replaceImage": "Remplacer l'image",
|
||||||
|
"selectFromGallery": "Sélectionner depuis la galerie",
|
||||||
|
"team": "Équipe",
|
||||||
|
"found": "Trouvé",
|
||||||
|
"staging": "Zone de regroupement",
|
||||||
|
"object": "Objet",
|
||||||
|
"quiet": "Calme",
|
||||||
|
"moderate": "Modéré",
|
||||||
|
"busy": "Occupé",
|
||||||
|
"spectrumScanReturnedNoCandidateFrequencies": "Le scan spectral n'a trouvé aucune fréquence candidate",
|
||||||
|
"searchMessages": "Rechercher des messages",
|
||||||
|
"sendImageFromGallery": "Envoyer une image de la galerie",
|
||||||
|
"takePhoto": "Prendre une photo",
|
||||||
|
"dmOnly": "Message direct uniquement",
|
||||||
|
"allMessages": "Tous les messages",
|
||||||
|
"sendToPublicChannel": "Envoyer au canal public ?",
|
||||||
|
"selectMarkerTypeAndDestination": "Sélectionnez le type de marqueur et la destination",
|
||||||
|
"noDestinationsAvailableLabel": "Aucune destination disponible",
|
||||||
|
"image": "Image",
|
||||||
|
"format": "Format",
|
||||||
|
"dimensions": "Dimensions",
|
||||||
|
"segments": "Segments",
|
||||||
|
"transfers": "Transferts",
|
||||||
|
"downloadedBy": "Téléchargé par",
|
||||||
|
"saveDiscoverySettings": "Enregistrer les paramètres de découverte",
|
||||||
|
"savePublicInfo": "Enregistrer les infos publiques",
|
||||||
|
"saveRadioSettings": "Enregistrer les paramètres radio",
|
||||||
|
"savePath": "Enregistrer le chemin",
|
||||||
|
"wipeDeviceData": "Effacer les données de l'appareil",
|
||||||
|
"wipeDevice": "Effacer l'appareil",
|
||||||
|
"destructiveDeviceActions": "Actions destructives de l'appareil.",
|
||||||
|
"chooseAPresetOrFinetuneCustomRadioSettings": "Choisissez un préréglage ou ajustez les paramètres radio.",
|
||||||
|
"chooseTheNameAndLocationThisDeviceShares": "Choisissez le nom et l'emplacement partagés par cet appareil.",
|
||||||
|
"availableSpaceOnThisDevice": "Espace disponible sur cet appareil.",
|
||||||
|
"used": "Utilisé",
|
||||||
|
"total": "Total",
|
||||||
|
"renameValue": "Renommer la valeur",
|
||||||
|
"customizeFields": "Personnaliser les champs",
|
||||||
|
"livePreview": "Aperçu en direct",
|
||||||
|
"refreshSchedule": "Planification d'actualisation",
|
||||||
|
"noResponse": "Pas de réponse",
|
||||||
|
"refreshing": "Actualisation",
|
||||||
|
"unavailable": "Indisponible",
|
||||||
|
"pickARelayOrNodeToWatchInSensors": "Choisissez un relais ou nœud à surveiller.",
|
||||||
|
"publicKeyLabel": "Clé publique",
|
||||||
|
"alreadyInContacts": "Déjà dans les contacts",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Connectez-vous à un appareil avant d'ajouter des contacts",
|
||||||
|
"fromContacts": "Des contacts",
|
||||||
|
"onlineOnly": "En ligne uniquement",
|
||||||
|
"inBoth": "Dans les deux",
|
||||||
|
"source": "Source",
|
||||||
|
"manualRouteEdit": "Édition manuelle de route",
|
||||||
|
"observedMeshRoute": "Route mesh observée",
|
||||||
|
"allMessagesCleared": "Tous les messages effacés",
|
||||||
|
"onlineTraceDatabaseCleared": "Base de données de traces effacée",
|
||||||
|
"packetLogsCleared": "Journaux de paquets effacés",
|
||||||
|
"hexDataCopiedToClipboard": "Données hex copiées dans le presse-papiers",
|
||||||
|
"developerModeEnabled": "Mode développeur activé",
|
||||||
|
"developerModeDisabled": "Mode développeur désactivé",
|
||||||
|
"clipboardIsEmpty": "Le presse-papiers est vide",
|
||||||
|
"contactImported": "Contact importé",
|
||||||
|
"contactLinkCopiedToClipboard": "Lien du contact copié dans le presse-papiers",
|
||||||
|
"failedToExportContact": "Échec de l'exportation du contact",
|
||||||
|
"noLogsToExport": "Aucun journal à exporter",
|
||||||
|
"exportAsCsv": "Exporter en CSV",
|
||||||
|
"exportAsText": "Exporter en texte",
|
||||||
|
"receivedRfc3339": "Reçu (RFC3339)",
|
||||||
|
"buildTime": "Heure de build",
|
||||||
|
"downloadUrlNotAvailable": "URL de téléchargement non disponible",
|
||||||
|
"cannotOpenDownloadUrl": "Impossible d'ouvrir l'URL de téléchargement",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "La vérification des mises à jour n'est disponible que sur Android",
|
||||||
|
"youAreRunningTheLatestVersion": "Vous utilisez la dernière version",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "Mise à jour disponible mais URL de téléchargement introuvable",
|
||||||
|
"startTictactoe": "Démarrer Tic-Tac-Toe",
|
||||||
|
"tictactoeUnavailable": "Tic-Tac-Toe indisponible",
|
||||||
|
"tictactoeOpponentUnknown": "Tic-Tac-Toe: adversaire inconnu",
|
||||||
|
"tictactoeWaitingForStart": "Tic-Tac-Toe: en attente du début",
|
||||||
|
"acceptsShareLinks": "Accepte les liens partagés",
|
||||||
|
"supportsRawHex": "Supporte le hex brut",
|
||||||
|
"clipboardfriendly": "Compatible avec le presse-papiers",
|
||||||
|
"captured": "Capturé",
|
||||||
|
"size": "Taille",
|
||||||
|
"noCustomChannelsToClear": "Aucun canal personnalisé à effacer.",
|
||||||
|
"noDeviceContactsToClear": "Aucun contact de l'appareil à effacer."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,5 +511,304 @@
|
|||||||
"rawDump": "Sirovi ispis",
|
"rawDump": "Sirovi ispis",
|
||||||
"cannotRetryMissingRecipient": "Nije moguće ponoviti: nedostaju informacije o primatelju",
|
"cannotRetryMissingRecipient": "Nije moguće ponoviti: nedostaju informacije o primatelju",
|
||||||
"voiceUnavailable": "Glas trenutno nije dostupan",
|
"voiceUnavailable": "Glas trenutno nije dostupan",
|
||||||
"requestingVoice": "Zahtjev za glasom"
|
"requestingVoice": "Zahtjev za glasom",
|
||||||
|
"device": "uređaj",
|
||||||
|
"change": "Promijeni",
|
||||||
|
"wizardOverviewDescription": "Ova aplikacija objedinjuje MeshCore poruke, SAR ažuriranja s terena, karte i alate za uređaj na jednom mjestu.",
|
||||||
|
"wizardOverviewFeature1": "Šaljite izravne poruke, objave u sobama i poruke kanala iz glavne kartice Poruke.",
|
||||||
|
"wizardOverviewFeature2": "Dijelite SAR oznake, crteže karte, glasovne isječke i slike preko mesh mreže.",
|
||||||
|
"wizardOverviewFeature3": "Povežite se putem BLE-a ili TCP-a, a zatim upravljajte pratećim radijem izravno iz aplikacije.",
|
||||||
|
"wizardMessagingTitle": "Poruke i terenska izvješća",
|
||||||
|
"wizardMessagingDescription": "Poruke ovdje nisu samo običan tekst. Aplikacija već podržava više operativnih sadržaja i tijekova prijenosa.",
|
||||||
|
"wizardMessagingFeature1": "Šaljite izravne poruke, objave u sobama i promet kanala iz jednog uređivača.",
|
||||||
|
"wizardMessagingFeature2": "Stvarajte SAR ažuriranja i višekratne SAR predloške za uobičajena terenska izvješća.",
|
||||||
|
"wizardMessagingFeature3": "Prenosite glasovne sesije i slike uz prikaz napretka i procjene vremena prijenosa u sučelju.",
|
||||||
|
"wizardConnectDeviceTitle": "Poveži uređaj",
|
||||||
|
"wizardConnectDeviceDescription": "Povežite svoj MeshCore radio, odaberite naziv i primijenite radijski predložak prije nastavka.",
|
||||||
|
"wizardSetupBadge": "Postavljanje",
|
||||||
|
"wizardOverviewBadge": "Pregled",
|
||||||
|
"wizardConnectedToDevice": "Povezano s uređajem {deviceName}",
|
||||||
|
"wizardNoDeviceConnected": "Još nema povezanog uređaja",
|
||||||
|
"wizardSkipForNow": "Preskoči za sada",
|
||||||
|
"wizardDeviceNameLabel": "Naziv uređaja",
|
||||||
|
"wizardDeviceNameHelp": "Taj se naziv oglašava drugim korisnicima MeshCorea.",
|
||||||
|
"wizardConfigRegionLabel": "Regija konfiguracije",
|
||||||
|
"wizardConfigRegionHelp": "Koristi puni službeni popis MeshCore predložaka. Zadana je opcija EU/UK (Narrow).",
|
||||||
|
"wizardPresetNote1": "Provjerite odgovara li odabrani predložak lokalnim radijskim propisima.",
|
||||||
|
"wizardPresetNote2": "Popis odgovara službenom izvoru predložaka alata MeshCore config.",
|
||||||
|
"wizardPresetNote3": "EU/UK (Narrow) ostaje zadano odabran tijekom uvodnog postavljanja.",
|
||||||
|
"wizardSaving": "Spremanje...",
|
||||||
|
"wizardSaveAndContinue": "Spremi i nastavi",
|
||||||
|
"wizardEnterDeviceName": "Unesite naziv uređaja prije nastavka.",
|
||||||
|
"wizardDeviceSetupSaved": "Spremljeno {deviceName} s postavkom {presetName}.",
|
||||||
|
"wizardNetworkTitle": "Kontakti, sobe i repetitori",
|
||||||
|
"wizardNetworkDescription": "Kartica Kontakti organizira mrežu koju otkrijete i rute koje s vremenom naučite.",
|
||||||
|
"wizardNetworkFeature1": "Pregledajte članove tima, repetitore, sobe, kanale i oglase na čekanju na jednom popisu.",
|
||||||
|
"wizardNetworkFeature2": "Koristite pametni ping, prijavu u sobe, naučene putanje i alate za resetiranje ruta kada povezanost postane neuredna.",
|
||||||
|
"wizardNetworkFeature3": "Stvarajte kanale i upravljajte mrežnim odredištima bez napuštanja aplikacije.",
|
||||||
|
"wizardMapOpsTitle": "Karta, tragovi i dijeljena geometrija",
|
||||||
|
"wizardMapOpsDescription": "Karta u aplikaciji izravno je povezana s porukama, praćenjem i SAR slojevima umjesto da bude zaseban preglednik.",
|
||||||
|
"wizardMapOpsFeature1": "Pratite vlastitu poziciju, lokacije suigrača i tragove kretanja na karti.",
|
||||||
|
"wizardMapOpsFeature2": "Otvorite crteže iz poruka, pregledajte ih unutar sučelja i po potrebi ih uklonite s karte.",
|
||||||
|
"wizardMapOpsFeature3": "Koristite karte repetitora i dijeljene slojeve kako biste razumjeli doseg mreže na terenu.",
|
||||||
|
"wizardToolsTitle": "Alati izvan poruka",
|
||||||
|
"wizardToolsDescription": "Ovdje postoji više od četiri glavne kartice. Aplikacija uključuje i konfiguraciju, dijagnostiku te opcionalne radne tokove senzora.",
|
||||||
|
"wizardToolsFeature1": "Otvorite konfiguraciju uređaja kako biste promijenili radijske postavke, telemetriju, TX snagu i detalje pratećeg uređaja.",
|
||||||
|
"wizardToolsFeature2": "Omogućite karticu Senzori kada želite nadzorne ploče praćenih senzora i brza osvježavanja.",
|
||||||
|
"wizardToolsFeature3": "Koristite zapisnike paketa, skeniranje spektra i razvojnu dijagnostiku pri rješavanju problema s mesh mrežom.",
|
||||||
|
"contactInSensors": "U Senzorima",
|
||||||
|
"contactAddToSensors": "Dodaj u Senzore",
|
||||||
|
"contactSetPath": "Postavi putanju",
|
||||||
|
"contactAddedToSensors": "{contactName} dodan u Senzore",
|
||||||
|
"contactFailedToClearRoute": "Brisanje rute nije uspjelo: {error}",
|
||||||
|
"contactRouteCleared": "Ruta obrisana",
|
||||||
|
"contactRouteSet": "Ruta postavljena: {route}",
|
||||||
|
"contactFailedToSetRoute": "Postavljanje rute nije uspjelo: {error}",
|
||||||
|
"rssi": "RSSI",
|
||||||
|
"snr": "SNR",
|
||||||
|
"ackTimeout": "ACK istek vremena",
|
||||||
|
"opcode": "Opkod",
|
||||||
|
"payload": "Sadržaj",
|
||||||
|
"hops": "Skokovi",
|
||||||
|
"hashSize": "Veličina hash-a",
|
||||||
|
"pathBytes": "Bajtovi putanje",
|
||||||
|
"selectedPath": "Odabrana putanja",
|
||||||
|
"estimatedTx": "Procijenjeno slanje",
|
||||||
|
"senderToReceipt": "Od pošiljatelja do primitka",
|
||||||
|
"receivedCopies": "Primljene kopije",
|
||||||
|
"retryCause": "Razlog ponovnog pokušaja",
|
||||||
|
"retryMode": "Način ponovnog pokušaja",
|
||||||
|
"retryResult": "Rezultat ponovnog pokušaja",
|
||||||
|
"lastRetry": "Zadnji pokušaj",
|
||||||
|
"rxPackets": "RX paketi",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Brzina",
|
||||||
|
"window": "Prozor",
|
||||||
|
"posttxDelay": "Kašnjenje nakon slanja",
|
||||||
|
"bandpass": "Pojasni filtar",
|
||||||
|
"bandpassFilterVoice": "Pojasni filtar glasa",
|
||||||
|
"frequency": "Frekvencija",
|
||||||
|
"australia": "Australija",
|
||||||
|
"australiaNarrow": "Australija (usko)",
|
||||||
|
"australiaQld": "Australija: QLD",
|
||||||
|
"australiaSaWa": "Australija: SA, WA",
|
||||||
|
"newZealand": "Novi Zeland",
|
||||||
|
"newZealandNarrow": "Novi Zeland (usko)",
|
||||||
|
"switzerland": "Švicarska",
|
||||||
|
"portugal433": "Portugal 433",
|
||||||
|
"portugal868": "Portugal 868",
|
||||||
|
"czechRepublicNarrow": "Češka (usko)",
|
||||||
|
"eu433mhzLongRange": "EU 433MHz (veliki domet)",
|
||||||
|
"euukDeprecated": "EU/UK (zastarjelo)",
|
||||||
|
"euukNarrow": "EU/UK (usko)",
|
||||||
|
"usacanadaRecommended": "SAD/Kanada (preporučeno)",
|
||||||
|
"vietnamDeprecated": "Vijetnam (zastarjelo)",
|
||||||
|
"vietnamNarrow": "Vijetnam (usko)",
|
||||||
|
"active": "Aktivno",
|
||||||
|
"addContact": "Dodaj kontakt",
|
||||||
|
"all": "Sve",
|
||||||
|
"autoResolve": "Automatski razriješi",
|
||||||
|
"clearAllLabel": "Očisti sve",
|
||||||
|
"clearRelays": "Očisti posrednike",
|
||||||
|
"clearFilters": "Očisti filtre",
|
||||||
|
"clearRoute": "Očisti putanju",
|
||||||
|
"clearMessages": "Očisti poruke",
|
||||||
|
"clearScale": "Očisti mjerilo",
|
||||||
|
"clearDiscoveries": "Očisti otkrića",
|
||||||
|
"clearOnlineTraceDatabase": "Očisti bazu mrežnih tragova",
|
||||||
|
"clearAllChannels": "Očisti sve kanale",
|
||||||
|
"clearAllContacts": "Očisti sve kontakte",
|
||||||
|
"clearChannels": "Očisti kanale",
|
||||||
|
"clearContacts": "Očisti kontakte",
|
||||||
|
"clearPathOnMaxRetry": "Očisti putanju kod maks. pokušaja",
|
||||||
|
"create": "Stvori",
|
||||||
|
"custom": "Prilagođeno",
|
||||||
|
"defaultValue": "Zadano",
|
||||||
|
"duplicate": "Dupliciraj",
|
||||||
|
"editName": "Uredi ime",
|
||||||
|
"open": "Otvori",
|
||||||
|
"paste": "Zalijepi",
|
||||||
|
"preview": "Pregled",
|
||||||
|
"remove": "Ukloni",
|
||||||
|
"rename": "Preimenuj",
|
||||||
|
"resolveAll": "Razriješi sve",
|
||||||
|
"send": "Pošalji",
|
||||||
|
"sendAnyway": "Svejedno pošalji",
|
||||||
|
"share": "Dijeli",
|
||||||
|
"shareContact": "Dijeli kontakt",
|
||||||
|
"trace": "Praćenje",
|
||||||
|
"use": "Koristi",
|
||||||
|
"useSelectedFrequency": "Koristi odabranu frekvenciju",
|
||||||
|
"discovery": "Otkrivanje",
|
||||||
|
"discoverRepeaters": "Otkrij posrednike",
|
||||||
|
"discoverSensors": "Otkrij senzore",
|
||||||
|
"repeaterDiscoverySent": "Otkrivanje posrednika poslano",
|
||||||
|
"sensorDiscoverySent": "Otkrivanje senzora poslano",
|
||||||
|
"clearedPendingDiscoveries": "Očišćena čekajuća otkrića.",
|
||||||
|
"autoDiscovery": "Automatsko otkrivanje",
|
||||||
|
"enableAutomaticAdding": "Omogući automatsko dodavanje",
|
||||||
|
"autoaddRepeaters": "Automatski dodaj posrednike",
|
||||||
|
"autoaddRoomServers": "Automatski dodaj poslužitelje soba",
|
||||||
|
"autoaddSensors": "Automatski dodaj senzore",
|
||||||
|
"autoaddUsers": "Automatski dodaj korisnike",
|
||||||
|
"overwriteOldestWhenFull": "Prepiši najstarije kad je puno",
|
||||||
|
"storage": "Pohrana",
|
||||||
|
"dangerZone": "Opasna zona",
|
||||||
|
"profiles": "Profili",
|
||||||
|
"favourites": "Favoriti",
|
||||||
|
"sensors": "Senzori",
|
||||||
|
"others": "Ostali",
|
||||||
|
"gpsModule": "GPS modul",
|
||||||
|
"liveTraffic": "Promet uživo",
|
||||||
|
"repeatersMap": "Karta posrednika",
|
||||||
|
"spectrumScan": "Spektralno skeniranje",
|
||||||
|
"blePacketLogs": "BLE zapisi paketa",
|
||||||
|
"onlineTraceDatabase": "Baza mrežnih tragova",
|
||||||
|
"routePathByteSize": "Veličina putanje u bajtima",
|
||||||
|
"messageNotifications": "Obavijesti o porukama",
|
||||||
|
"sarAlerts": "SAR upozorenja",
|
||||||
|
"discoveryNotifications": "Obavijesti o otkrivanju",
|
||||||
|
"updateNotifications": "Obavijesti o ažuriranju",
|
||||||
|
"muteWhileAppIsOpen": "Utišaj dok je aplikacija otvorena",
|
||||||
|
"disableContacts": "Onemogući kontakte",
|
||||||
|
"enableSensorsTab": "Omogući karticu Senzori",
|
||||||
|
"enableProfiles": "Omogući profile",
|
||||||
|
"autoRouteRotation": "Automatsko rotiranje putanje",
|
||||||
|
"nearestRepeaterFallback": "Najbliži posrednik kao rezerva",
|
||||||
|
"deleteAllStoredMessageHistory": "Izbriši svu pohranjenu povijest poruka",
|
||||||
|
"messageFontSize": "Veličina fonta poruka",
|
||||||
|
"rotateMapWithHeading": "Rotiraj kartu prema smjeru",
|
||||||
|
"showMapDebugInfo": "Prikaži debug info karte",
|
||||||
|
"openMapInFullscreen": "Otvori kartu na cijelom zaslonu",
|
||||||
|
"showSarMarkersLabel": "Prikaži SAR oznake",
|
||||||
|
"displaySarMarkersOnTheMainMap": "Prikaži SAR oznake na glavnoj karti",
|
||||||
|
"showAllContactTrailsLabel": "Prikaži sve tragove kontakata",
|
||||||
|
"hideRepeatersOnMap": "Sakrij posrednike na karti",
|
||||||
|
"setMapScale": "Postavi mjerilo karte",
|
||||||
|
"customMapScaleSaved": "Prilagođeno mjerilo karte spremljeno",
|
||||||
|
"voiceBitrate": "Bitna brzina glasa",
|
||||||
|
"voiceCompressor": "Kompresor glasa",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Izravnava tihe i glasne razine govora",
|
||||||
|
"voiceLimiter": "Ograničivač glasa",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Sprječava rezanje vrhova prije kodiranja",
|
||||||
|
"micAutoGain": "Automatsko pojačanje mikrofona",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Dopušta snimaču prilagodbu razine ulaza",
|
||||||
|
"echoCancellation": "Poništavanje odjeka",
|
||||||
|
"noiseSuppression": "Potiskivanje šuma",
|
||||||
|
"trimSilenceInVoiceMessages": "Izreži tišinu u glasovnim porukama",
|
||||||
|
"compressor": "Kompresor",
|
||||||
|
"limiter": "Ograničivač",
|
||||||
|
"autoGain": "Automatsko pojačanje",
|
||||||
|
"echoCancel": "Odjek",
|
||||||
|
"noiseSuppress": "Šum",
|
||||||
|
"silenceTrim": "Tišina",
|
||||||
|
"maxImageSize": "Maksimalna veličina slike",
|
||||||
|
"imageCompression": "Kompresija slike",
|
||||||
|
"grayscale": "Sivine",
|
||||||
|
"ultraMode": "Ultra način",
|
||||||
|
"fastPrivateGpsUpdates": "Brza privatna GPS ažuriranja",
|
||||||
|
"movementThreshold": "Prag kretanja",
|
||||||
|
"fastGpsMovementThreshold": "Prag kretanja za brzi GPS",
|
||||||
|
"fastGpsActiveuseInterval": "Interval aktivnog korištenja brzog GPS-a",
|
||||||
|
"activeuseUpdateInterval": "Interval ažuriranja pri aktivnom korištenju",
|
||||||
|
"repeatNearbyTraffic": "Ponovi obližnji promet",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Proslijedi kroz posrednike kroz mrežu",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Samo obližnji, bez preplavljivanja posrednika",
|
||||||
|
"multihop": "Višeskočno",
|
||||||
|
"createProfile": "Stvori profil",
|
||||||
|
"renameProfile": "Preimenuj profil",
|
||||||
|
"newProfile": "Novi profil",
|
||||||
|
"manageProfiles": "Upravljaj profilima",
|
||||||
|
"enableProfilesToStartManagingThem": "Omogućite profile da biste ih počeli upravljati.",
|
||||||
|
"openMessage": "Otvori poruku",
|
||||||
|
"jumpToTheRelatedSarMessage": "Skoči na povezanu SAR poruku",
|
||||||
|
"removeSarMarker": "Ukloni SAR oznaku",
|
||||||
|
"pleaseSelectADestinationToSendSarMarker": "Odaberite odredište za slanje SAR oznake",
|
||||||
|
"sarMarkerBroadcastToPublicChannel": "SAR oznaka emitirana na javni kanal",
|
||||||
|
"sarMarkerSentToRoom": "SAR oznaka poslana u sobu",
|
||||||
|
"loadFromGallery": "Učitaj iz galerije",
|
||||||
|
"replaceImage": "Zamijeni sliku",
|
||||||
|
"selectFromGallery": "Odaberi iz galerije",
|
||||||
|
"team": "Tim",
|
||||||
|
"found": "Pronađeno",
|
||||||
|
"staging": "Sabirno mjesto",
|
||||||
|
"object": "Objekt",
|
||||||
|
"quiet": "Tiho",
|
||||||
|
"moderate": "Umjereno",
|
||||||
|
"busy": "Zauzeto",
|
||||||
|
"spectrumScanReturnedNoCandidateFrequencies": "Spektralno skeniranje nije pronašlo prikladne frekvencije",
|
||||||
|
"searchMessages": "Pretraži poruke",
|
||||||
|
"sendImageFromGallery": "Pošalji sliku iz galerije",
|
||||||
|
"takePhoto": "Snimi fotografiju",
|
||||||
|
"dmOnly": "Samo izravno",
|
||||||
|
"allMessages": "Sve poruke",
|
||||||
|
"sendToPublicChannel": "Pošalji na javni kanal?",
|
||||||
|
"selectMarkerTypeAndDestination": "Odaberite vrstu oznake i odredište",
|
||||||
|
"noDestinationsAvailableLabel": "Nema dostupnih odredišta",
|
||||||
|
"image": "Slika",
|
||||||
|
"format": "Format",
|
||||||
|
"dimensions": "Dimenzije",
|
||||||
|
"segments": "Segmenti",
|
||||||
|
"transfers": "Prijenosi",
|
||||||
|
"downloadedBy": "Preuzeo",
|
||||||
|
"saveDiscoverySettings": "Spremi postavke otkrivanja",
|
||||||
|
"savePublicInfo": "Spremi javne podatke",
|
||||||
|
"saveRadioSettings": "Spremi radijske postavke",
|
||||||
|
"savePath": "Spremi putanju",
|
||||||
|
"wipeDeviceData": "Obriši podatke uređaja",
|
||||||
|
"wipeDevice": "Obriši uređaj",
|
||||||
|
"destructiveDeviceActions": "Destruktivne radnje na uređaju.",
|
||||||
|
"chooseAPresetOrFinetuneCustomRadioSettings": "Odaberite predefiniranu postavku ili fino podesite radijske postavke.",
|
||||||
|
"chooseTheNameAndLocationThisDeviceShares": "Odaberite ime i lokaciju koju ovaj uređaj dijeli.",
|
||||||
|
"availableSpaceOnThisDevice": "Raspoloživi prostor na ovom uređaju.",
|
||||||
|
"used": "Korišteno",
|
||||||
|
"total": "Ukupno",
|
||||||
|
"renameValue": "Preimenuj vrijednost",
|
||||||
|
"customizeFields": "Prilagodi polja",
|
||||||
|
"livePreview": "Pregled uživo",
|
||||||
|
"refreshSchedule": "Raspored osvježavanja",
|
||||||
|
"noResponse": "Nema odgovora",
|
||||||
|
"refreshing": "Osvježavam",
|
||||||
|
"unavailable": "Nedostupno",
|
||||||
|
"pickARelayOrNodeToWatchInSensors": "Odaberite posrednika ili čvor za praćenje.",
|
||||||
|
"publicKeyLabel": "Javni ključ",
|
||||||
|
"alreadyInContacts": "Već u kontaktima",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Povežite se s uređajem prije dodavanja kontakata",
|
||||||
|
"fromContacts": "Iz kontakata",
|
||||||
|
"onlineOnly": "Samo povezani",
|
||||||
|
"inBoth": "U oba",
|
||||||
|
"source": "Izvor",
|
||||||
|
"manualRouteEdit": "Ručno uređivanje putanje",
|
||||||
|
"observedMeshRoute": "Opažena mrežna putanja",
|
||||||
|
"allMessagesCleared": "Sve poruke očišćene",
|
||||||
|
"onlineTraceDatabaseCleared": "Baza mrežnih tragova očišćena",
|
||||||
|
"packetLogsCleared": "Zapisi paketa očišćeni",
|
||||||
|
"hexDataCopiedToClipboard": "Hex podaci kopirani u međuspremnik",
|
||||||
|
"developerModeEnabled": "Razvojni način omogućen",
|
||||||
|
"developerModeDisabled": "Razvojni način onemogućen",
|
||||||
|
"clipboardIsEmpty": "Međuspremnik je prazan",
|
||||||
|
"contactImported": "Kontakt uvezen",
|
||||||
|
"contactLinkCopiedToClipboard": "Poveznica na kontakt kopirana u međuspremnik",
|
||||||
|
"failedToExportContact": "Izvoz kontakta nije uspio",
|
||||||
|
"noLogsToExport": "Nema zapisa za izvoz",
|
||||||
|
"exportAsCsv": "Izvezi kao CSV",
|
||||||
|
"exportAsText": "Izvezi kao tekst",
|
||||||
|
"receivedRfc3339": "Primljeno (RFC3339)",
|
||||||
|
"buildTime": "Vrijeme gradnje",
|
||||||
|
"downloadUrlNotAvailable": "URL za preuzimanje nije dostupan",
|
||||||
|
"cannotOpenDownloadUrl": "Ne mogu otvoriti URL za preuzimanje",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "Provjera ažuriranja dostupna je samo na Androidu",
|
||||||
|
"youAreRunningTheLatestVersion": "Koristite najnoviju verziju",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "Ažuriranje dostupno, ali URL za preuzimanje nije pronađen",
|
||||||
|
"startTictactoe": "Pokreni Tic-Tac-Toe",
|
||||||
|
"tictactoeUnavailable": "Tic-Tac-Toe nije dostupan",
|
||||||
|
"tictactoeOpponentUnknown": "Tic-Tac-Toe: protivnik nepoznat",
|
||||||
|
"tictactoeWaitingForStart": "Tic-Tac-Toe: čekanje na početak",
|
||||||
|
"acceptsShareLinks": "Prihvaća dijeljene veze",
|
||||||
|
"supportsRawHex": "Podržava sirovi hex",
|
||||||
|
"clipboardfriendly": "Pogodno za međuspremnik",
|
||||||
|
"captured": "Zabilježeno",
|
||||||
|
"size": "Veličina",
|
||||||
|
"noCustomChannelsToClear": "Nema prilagođenih kanala za brisanje.",
|
||||||
|
"noDeviceContactsToClear": "Nema kontakata uređaja za brisanje."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1589,5 +1589,304 @@
|
|||||||
"rawDump": "Dump grezzo",
|
"rawDump": "Dump grezzo",
|
||||||
"cannotRetryMissingRecipient": "Impossibile riprovare: informazioni destinatario mancanti",
|
"cannotRetryMissingRecipient": "Impossibile riprovare: informazioni destinatario mancanti",
|
||||||
"voiceUnavailable": "Voce non disponibile al momento",
|
"voiceUnavailable": "Voce non disponibile al momento",
|
||||||
"requestingVoice": "Richiesta voce"
|
"requestingVoice": "Richiesta voce",
|
||||||
|
"device": "dispositivo",
|
||||||
|
"change": "Modifica",
|
||||||
|
"wizardOverviewDescription": "Questa app riunisce messaggistica MeshCore, aggiornamenti SAR sul campo, mappe e strumenti del dispositivo in un unico posto.",
|
||||||
|
"wizardOverviewFeature1": "Invia messaggi diretti, post nelle stanze e messaggi di canale dalla scheda principale Messaggi.",
|
||||||
|
"wizardOverviewFeature2": "Condividi marcatori SAR, disegni sulla mappa, clip vocali e immagini sulla mesh.",
|
||||||
|
"wizardOverviewFeature3": "Connettiti tramite BLE o TCP e poi gestisci la radio companion direttamente dall'app.",
|
||||||
|
"wizardMessagingTitle": "Messaggistica e rapporti sul campo",
|
||||||
|
"wizardMessagingDescription": "Qui i messaggi sono più del semplice testo. L'app supporta già diversi payload operativi e flussi di trasferimento.",
|
||||||
|
"wizardMessagingFeature1": "Invia messaggi diretti, post nelle stanze e traffico di canale da un unico compositore.",
|
||||||
|
"wizardMessagingFeature2": "Crea aggiornamenti SAR e modelli SAR riutilizzabili per i rapporti sul campo più comuni.",
|
||||||
|
"wizardMessagingFeature3": "Trasferisci sessioni vocali e immagini, con avanzamento e stime del tempo d'aria nell'interfaccia.",
|
||||||
|
"wizardConnectDeviceTitle": "Connetti dispositivo",
|
||||||
|
"wizardConnectDeviceDescription": "Connetti la tua radio MeshCore, scegli un nome e applica un preset radio prima di continuare.",
|
||||||
|
"wizardSetupBadge": "Configurazione",
|
||||||
|
"wizardOverviewBadge": "Panoramica",
|
||||||
|
"wizardConnectedToDevice": "Connesso a {deviceName}",
|
||||||
|
"wizardNoDeviceConnected": "Nessun dispositivo ancora connesso",
|
||||||
|
"wizardSkipForNow": "Salta per ora",
|
||||||
|
"wizardDeviceNameLabel": "Nome del dispositivo",
|
||||||
|
"wizardDeviceNameHelp": "Questo nome viene annunciato agli altri utenti MeshCore.",
|
||||||
|
"wizardConfigRegionLabel": "Regione di configurazione",
|
||||||
|
"wizardConfigRegionHelp": "Usa l'elenco ufficiale completo dei preset MeshCore. Il valore predefinito è EU/UK (Narrow).",
|
||||||
|
"wizardPresetNote1": "Assicurati che il preset selezionato corrisponda alle normative radio locali.",
|
||||||
|
"wizardPresetNote2": "L'elenco corrisponde al feed ufficiale dei preset dello strumento di configurazione MeshCore.",
|
||||||
|
"wizardPresetNote3": "EU/UK (Narrow) resta selezionato per impostazione predefinita durante l'onboarding.",
|
||||||
|
"wizardSaving": "Salvataggio...",
|
||||||
|
"wizardSaveAndContinue": "Salva e continua",
|
||||||
|
"wizardEnterDeviceName": "Inserisci un nome dispositivo prima di continuare.",
|
||||||
|
"wizardDeviceSetupSaved": "{deviceName} salvato con {presetName}.",
|
||||||
|
"wizardNetworkTitle": "Contatti, stanze e ripetitori",
|
||||||
|
"wizardNetworkDescription": "La scheda Contatti organizza la rete che scopri e i percorsi che apprendi nel tempo.",
|
||||||
|
"wizardNetworkFeature1": "Controlla membri del team, ripetitori, stanze, canali e annunci in sospeso in un unico elenco.",
|
||||||
|
"wizardNetworkFeature2": "Usa smart ping, accesso alle stanze, percorsi appresi e strumenti di reset delle route quando la connettività diventa complicata.",
|
||||||
|
"wizardNetworkFeature3": "Crea canali e gestisci le destinazioni di rete senza uscire dall'app.",
|
||||||
|
"wizardMapOpsTitle": "Mappa, tracce e geometria condivisa",
|
||||||
|
"wizardMapOpsDescription": "La mappa dell'app è collegata direttamente a messaggistica, tracciamento e overlay SAR invece di essere un visualizzatore separato.",
|
||||||
|
"wizardMapOpsFeature1": "Monitora la tua posizione, le posizioni dei compagni e le tracce di movimento sulla mappa.",
|
||||||
|
"wizardMapOpsFeature2": "Apri i disegni dai messaggi, visualizzali in anteprima in linea e rimuovili dalla mappa quando serve.",
|
||||||
|
"wizardMapOpsFeature3": "Usa le viste mappa dei ripetitori e gli overlay condivisi per capire la copertura della rete sul campo.",
|
||||||
|
"wizardToolsTitle": "Strumenti oltre la messaggistica",
|
||||||
|
"wizardToolsDescription": "Qui c'è più delle quattro schede principali. L'app include anche configurazione, diagnostica e flussi sensori opzionali.",
|
||||||
|
"wizardToolsFeature1": "Apri la configurazione del dispositivo per cambiare impostazioni radio, telemetria, potenza TX e dettagli del companion.",
|
||||||
|
"wizardToolsFeature2": "Abilita la scheda Sensori quando vuoi dashboard monitorate e azioni di aggiornamento rapido.",
|
||||||
|
"wizardToolsFeature3": "Usa log dei pacchetti, scansione dello spettro e diagnostica sviluppatore per risolvere problemi della mesh.",
|
||||||
|
"contactInSensors": "Nei Sensori",
|
||||||
|
"contactAddToSensors": "Aggiungi ai Sensori",
|
||||||
|
"contactSetPath": "Imposta percorso",
|
||||||
|
"contactAddedToSensors": "{contactName} aggiunto a Sensori",
|
||||||
|
"contactFailedToClearRoute": "Impossibile cancellare il percorso: {error}",
|
||||||
|
"contactRouteCleared": "Percorso cancellato",
|
||||||
|
"contactRouteSet": "Percorso impostato: {route}",
|
||||||
|
"contactFailedToSetRoute": "Impossibile impostare il percorso: {error}",
|
||||||
|
"rssi": "RSSI",
|
||||||
|
"snr": "SNR",
|
||||||
|
"ackTimeout": "Timeout ACK",
|
||||||
|
"opcode": "Opcode",
|
||||||
|
"payload": "Payload",
|
||||||
|
"hops": "Hop",
|
||||||
|
"hashSize": "Dimensione hash",
|
||||||
|
"pathBytes": "Byte percorso",
|
||||||
|
"selectedPath": "Percorso selezionato",
|
||||||
|
"estimatedTx": "Trasmissione stimata",
|
||||||
|
"senderToReceipt": "Mittente a ricevuta",
|
||||||
|
"receivedCopies": "Copie ricevute",
|
||||||
|
"retryCause": "Causa del ritentativo",
|
||||||
|
"retryMode": "Modalità ritentativo",
|
||||||
|
"retryResult": "Risultato ritentativo",
|
||||||
|
"lastRetry": "Ultimo ritentativo",
|
||||||
|
"rxPackets": "Pacchetti RX",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Velocità",
|
||||||
|
"window": "Finestra",
|
||||||
|
"posttxDelay": "Ritardo post-tx",
|
||||||
|
"bandpass": "Passa-banda",
|
||||||
|
"bandpassFilterVoice": "Filtro passa-banda voce",
|
||||||
|
"frequency": "Frequenza",
|
||||||
|
"australia": "Australia",
|
||||||
|
"australiaNarrow": "Australia (Stretto)",
|
||||||
|
"australiaQld": "Australia: QLD",
|
||||||
|
"australiaSaWa": "Australia: SA, WA",
|
||||||
|
"newZealand": "Nuova Zelanda",
|
||||||
|
"newZealandNarrow": "Nuova Zelanda (Stretto)",
|
||||||
|
"switzerland": "Svizzera",
|
||||||
|
"portugal433": "Portogallo 433",
|
||||||
|
"portugal868": "Portogallo 868",
|
||||||
|
"czechRepublicNarrow": "Repubblica Ceca (Stretto)",
|
||||||
|
"eu433mhzLongRange": "UE 433MHz (Lungo Raggio)",
|
||||||
|
"euukDeprecated": "UE/UK (Deprecato)",
|
||||||
|
"euukNarrow": "UE/UK (Stretto)",
|
||||||
|
"usacanadaRecommended": "USA/Canada (Consigliato)",
|
||||||
|
"vietnamDeprecated": "Vietnam (Deprecato)",
|
||||||
|
"vietnamNarrow": "Vietnam (Stretto)",
|
||||||
|
"active": "Attivo",
|
||||||
|
"addContact": "Aggiungi contatto",
|
||||||
|
"all": "Tutto",
|
||||||
|
"autoResolve": "Risolvi automaticamente",
|
||||||
|
"clearAllLabel": "Cancella tutto",
|
||||||
|
"clearRelays": "Cancella relay",
|
||||||
|
"clearFilters": "Cancella filtri",
|
||||||
|
"clearRoute": "Cancella percorso",
|
||||||
|
"clearMessages": "Cancella messaggi",
|
||||||
|
"clearScale": "Cancella scala",
|
||||||
|
"clearDiscoveries": "Cancella scoperte",
|
||||||
|
"clearOnlineTraceDatabase": "Cancella database tracce",
|
||||||
|
"clearAllChannels": "Cancella tutti i canali",
|
||||||
|
"clearAllContacts": "Cancella tutti i contatti",
|
||||||
|
"clearChannels": "Cancella canali",
|
||||||
|
"clearContacts": "Cancella contatti",
|
||||||
|
"clearPathOnMaxRetry": "Cancella percorso al max ritentativo",
|
||||||
|
"create": "Crea",
|
||||||
|
"custom": "Personalizzato",
|
||||||
|
"defaultValue": "Predefinito",
|
||||||
|
"duplicate": "Duplica",
|
||||||
|
"editName": "Modifica nome",
|
||||||
|
"open": "Apri",
|
||||||
|
"paste": "Incolla",
|
||||||
|
"preview": "Anteprima",
|
||||||
|
"remove": "Rimuovi",
|
||||||
|
"rename": "Rinomina",
|
||||||
|
"resolveAll": "Risolvi tutto",
|
||||||
|
"send": "Invia",
|
||||||
|
"sendAnyway": "Invia comunque",
|
||||||
|
"share": "Condividi",
|
||||||
|
"shareContact": "Condividi contatto",
|
||||||
|
"trace": "Traccia",
|
||||||
|
"use": "Usa",
|
||||||
|
"useSelectedFrequency": "Usa frequenza selezionata",
|
||||||
|
"discovery": "Scoperta",
|
||||||
|
"discoverRepeaters": "Scopri ripetitori",
|
||||||
|
"discoverSensors": "Scopri sensori",
|
||||||
|
"repeaterDiscoverySent": "Scoperta ripetitori inviata",
|
||||||
|
"sensorDiscoverySent": "Scoperta sensori inviata",
|
||||||
|
"clearedPendingDiscoveries": "Scoperte in attesa cancellate.",
|
||||||
|
"autoDiscovery": "Scoperta automatica",
|
||||||
|
"enableAutomaticAdding": "Abilita aggiunta automatica",
|
||||||
|
"autoaddRepeaters": "Aggiungi ripetitori automaticamente",
|
||||||
|
"autoaddRoomServers": "Aggiungi server stanze automaticamente",
|
||||||
|
"autoaddSensors": "Aggiungi sensori automaticamente",
|
||||||
|
"autoaddUsers": "Aggiungi utenti automaticamente",
|
||||||
|
"overwriteOldestWhenFull": "Sovrascrivi i più vecchi quando pieno",
|
||||||
|
"storage": "Archiviazione",
|
||||||
|
"dangerZone": "Zona pericolosa",
|
||||||
|
"profiles": "Profili",
|
||||||
|
"favourites": "Preferiti",
|
||||||
|
"sensors": "Sensori",
|
||||||
|
"others": "Altri",
|
||||||
|
"gpsModule": "Modulo GPS",
|
||||||
|
"liveTraffic": "Traffico in tempo reale",
|
||||||
|
"repeatersMap": "Mappa ripetitori",
|
||||||
|
"spectrumScan": "Scansione spettro",
|
||||||
|
"blePacketLogs": "Log pacchetti BLE",
|
||||||
|
"onlineTraceDatabase": "Database tracce",
|
||||||
|
"routePathByteSize": "Dimensione percorso in byte",
|
||||||
|
"messageNotifications": "Notifiche messaggi",
|
||||||
|
"sarAlerts": "Avvisi SAR",
|
||||||
|
"discoveryNotifications": "Notifiche scoperte",
|
||||||
|
"updateNotifications": "Notifiche aggiornamenti",
|
||||||
|
"muteWhileAppIsOpen": "Silenzia con l'app aperta",
|
||||||
|
"disableContacts": "Disabilita contatti",
|
||||||
|
"enableSensorsTab": "Abilita scheda Sensori",
|
||||||
|
"enableProfiles": "Abilita profili",
|
||||||
|
"autoRouteRotation": "Rotazione automatica percorso",
|
||||||
|
"nearestRepeaterFallback": "Ripetitore più vicino come fallback",
|
||||||
|
"deleteAllStoredMessageHistory": "Elimina tutta la cronologia messaggi",
|
||||||
|
"messageFontSize": "Dimensione carattere messaggi",
|
||||||
|
"rotateMapWithHeading": "Ruota mappa con direzione",
|
||||||
|
"showMapDebugInfo": "Mostra info debug mappa",
|
||||||
|
"openMapInFullscreen": "Apri mappa a schermo intero",
|
||||||
|
"showSarMarkersLabel": "Mostra marcatori SAR",
|
||||||
|
"displaySarMarkersOnTheMainMap": "Mostra marcatori SAR sulla mappa principale",
|
||||||
|
"showAllContactTrailsLabel": "Mostra tutti i percorsi contatti",
|
||||||
|
"hideRepeatersOnMap": "Nascondi ripetitori sulla mappa",
|
||||||
|
"setMapScale": "Imposta scala mappa",
|
||||||
|
"customMapScaleSaved": "Scala mappa personalizzata salvata",
|
||||||
|
"voiceBitrate": "Bitrate voce",
|
||||||
|
"voiceCompressor": "Compressore voce",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Bilancia i livelli di voce bassi e alti",
|
||||||
|
"voiceLimiter": "Limitatore voce",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Previene il clipping prima della codifica",
|
||||||
|
"micAutoGain": "Guadagno automatico microfono",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Permette al registratore di regolare il livello",
|
||||||
|
"echoCancellation": "Cancellazione eco",
|
||||||
|
"noiseSuppression": "Soppressione rumore",
|
||||||
|
"trimSilenceInVoiceMessages": "Taglia silenzio nei messaggi vocali",
|
||||||
|
"compressor": "Compressore",
|
||||||
|
"limiter": "Limitatore",
|
||||||
|
"autoGain": "Guadagno automatico",
|
||||||
|
"echoCancel": "Eco",
|
||||||
|
"noiseSuppress": "Rumore",
|
||||||
|
"silenceTrim": "Silenzio",
|
||||||
|
"maxImageSize": "Dimensione massima immagine",
|
||||||
|
"imageCompression": "Compressione immagine",
|
||||||
|
"grayscale": "Scala di grigi",
|
||||||
|
"ultraMode": "Modalità ultra",
|
||||||
|
"fastPrivateGpsUpdates": "Aggiornamenti GPS privati veloci",
|
||||||
|
"movementThreshold": "Soglia di movimento",
|
||||||
|
"fastGpsMovementThreshold": "Soglia movimento GPS veloce",
|
||||||
|
"fastGpsActiveuseInterval": "Intervallo uso attivo GPS veloce",
|
||||||
|
"activeuseUpdateInterval": "Intervallo aggiornamento uso attivo",
|
||||||
|
"repeatNearbyTraffic": "Ripeti traffico vicino",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Inoltra tramite ripetitori nella rete",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Solo vicini, senza flood dei ripetitori",
|
||||||
|
"multihop": "Multi-hop",
|
||||||
|
"createProfile": "Crea profilo",
|
||||||
|
"renameProfile": "Rinomina profilo",
|
||||||
|
"newProfile": "Nuovo profilo",
|
||||||
|
"manageProfiles": "Gestisci profili",
|
||||||
|
"enableProfilesToStartManagingThem": "Abilita i profili per iniziare a gestirli.",
|
||||||
|
"openMessage": "Apri messaggio",
|
||||||
|
"jumpToTheRelatedSarMessage": "Vai al messaggio SAR correlato",
|
||||||
|
"removeSarMarker": "Rimuovi marcatore SAR",
|
||||||
|
"pleaseSelectADestinationToSendSarMarker": "Seleziona una destinazione per inviare il marcatore SAR",
|
||||||
|
"sarMarkerBroadcastToPublicChannel": "Marcatore SAR trasmesso al canale pubblico",
|
||||||
|
"sarMarkerSentToRoom": "Marcatore SAR inviato alla stanza",
|
||||||
|
"loadFromGallery": "Carica dalla galleria",
|
||||||
|
"replaceImage": "Sostituisci immagine",
|
||||||
|
"selectFromGallery": "Seleziona dalla galleria",
|
||||||
|
"team": "Squadra",
|
||||||
|
"found": "Trovato",
|
||||||
|
"staging": "Area di raccolta",
|
||||||
|
"object": "Oggetto",
|
||||||
|
"quiet": "Silenzioso",
|
||||||
|
"moderate": "Moderato",
|
||||||
|
"busy": "Occupato",
|
||||||
|
"spectrumScanReturnedNoCandidateFrequencies": "La scansione spettro non ha trovato frequenze candidate",
|
||||||
|
"searchMessages": "Cerca messaggi",
|
||||||
|
"sendImageFromGallery": "Invia immagine dalla galleria",
|
||||||
|
"takePhoto": "Scatta foto",
|
||||||
|
"dmOnly": "Solo messaggio diretto",
|
||||||
|
"allMessages": "Tutti i messaggi",
|
||||||
|
"sendToPublicChannel": "Inviare al canale pubblico?",
|
||||||
|
"selectMarkerTypeAndDestination": "Seleziona tipo marcatore e destinazione",
|
||||||
|
"noDestinationsAvailableLabel": "Nessuna destinazione disponibile",
|
||||||
|
"image": "Immagine",
|
||||||
|
"format": "Formato",
|
||||||
|
"dimensions": "Dimensioni",
|
||||||
|
"segments": "Segmenti",
|
||||||
|
"transfers": "Trasferimenti",
|
||||||
|
"downloadedBy": "Scaricato da",
|
||||||
|
"saveDiscoverySettings": "Salva impostazioni scoperta",
|
||||||
|
"savePublicInfo": "Salva info pubbliche",
|
||||||
|
"saveRadioSettings": "Salva impostazioni radio",
|
||||||
|
"savePath": "Salva percorso",
|
||||||
|
"wipeDeviceData": "Cancella dati dispositivo",
|
||||||
|
"wipeDevice": "Cancella dispositivo",
|
||||||
|
"destructiveDeviceActions": "Azioni distruttive del dispositivo.",
|
||||||
|
"chooseAPresetOrFinetuneCustomRadioSettings": "Scegli un preset o personalizza le impostazioni radio.",
|
||||||
|
"chooseTheNameAndLocationThisDeviceShares": "Scegli il nome e la posizione condivisi da questo dispositivo.",
|
||||||
|
"availableSpaceOnThisDevice": "Spazio disponibile su questo dispositivo.",
|
||||||
|
"used": "Usato",
|
||||||
|
"total": "Totale",
|
||||||
|
"renameValue": "Rinomina valore",
|
||||||
|
"customizeFields": "Personalizza campi",
|
||||||
|
"livePreview": "Anteprima dal vivo",
|
||||||
|
"refreshSchedule": "Programma aggiornamento",
|
||||||
|
"noResponse": "Nessuna risposta",
|
||||||
|
"refreshing": "Aggiornamento",
|
||||||
|
"unavailable": "Non disponibile",
|
||||||
|
"pickARelayOrNodeToWatchInSensors": "Scegli un relay o nodo da monitorare.",
|
||||||
|
"publicKeyLabel": "Chiave pubblica",
|
||||||
|
"alreadyInContacts": "Già nei contatti",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Connettiti a un dispositivo prima di aggiungere contatti",
|
||||||
|
"fromContacts": "Dai contatti",
|
||||||
|
"onlineOnly": "Solo online",
|
||||||
|
"inBoth": "In entrambi",
|
||||||
|
"source": "Origine",
|
||||||
|
"manualRouteEdit": "Modifica manuale percorso",
|
||||||
|
"observedMeshRoute": "Percorso mesh osservato",
|
||||||
|
"allMessagesCleared": "Tutti i messaggi cancellati",
|
||||||
|
"onlineTraceDatabaseCleared": "Database tracce cancellata",
|
||||||
|
"packetLogsCleared": "Log pacchetti cancellati",
|
||||||
|
"hexDataCopiedToClipboard": "Dati hex copiati negli appunti",
|
||||||
|
"developerModeEnabled": "Modalità sviluppatore attivata",
|
||||||
|
"developerModeDisabled": "Modalità sviluppatore disattivata",
|
||||||
|
"clipboardIsEmpty": "Gli appunti sono vuoti",
|
||||||
|
"contactImported": "Contatto importato",
|
||||||
|
"contactLinkCopiedToClipboard": "Link contatto copiato negli appunti",
|
||||||
|
"failedToExportContact": "Esportazione contatto fallita",
|
||||||
|
"noLogsToExport": "Nessun log da esportare",
|
||||||
|
"exportAsCsv": "Esporta come CSV",
|
||||||
|
"exportAsText": "Esporta come testo",
|
||||||
|
"receivedRfc3339": "Ricevuto (RFC3339)",
|
||||||
|
"buildTime": "Ora build",
|
||||||
|
"downloadUrlNotAvailable": "URL di download non disponibile",
|
||||||
|
"cannotOpenDownloadUrl": "Impossibile aprire URL di download",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "Il controllo aggiornamenti è disponibile solo su Android",
|
||||||
|
"youAreRunningTheLatestVersion": "Stai utilizzando l'ultima versione",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "Aggiornamento disponibile ma URL di download non trovato",
|
||||||
|
"startTictactoe": "Avvia Tic-Tac-Toe",
|
||||||
|
"tictactoeUnavailable": "Tic-Tac-Toe non disponibile",
|
||||||
|
"tictactoeOpponentUnknown": "Tic-Tac-Toe: avversario sconosciuto",
|
||||||
|
"tictactoeWaitingForStart": "Tic-Tac-Toe: in attesa dell'inizio",
|
||||||
|
"acceptsShareLinks": "Accetta link condivisi",
|
||||||
|
"supportsRawHex": "Supporta hex grezzo",
|
||||||
|
"clipboardfriendly": "Compatibile con appunti",
|
||||||
|
"captured": "Catturato",
|
||||||
|
"size": "Dimensione",
|
||||||
|
"noCustomChannelsToClear": "Nessun canale personalizzato da cancellare.",
|
||||||
|
"noDeviceContactsToClear": "Nessun contatto dispositivo da cancellare."
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1656,4 +1656,959 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get requestingVoice => '正在请求语音';
|
String get requestingVoice => '正在请求语音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get device => '设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get change => '更改';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardOverviewDescription =>
|
||||||
|
'此应用将 MeshCore 消息、SAR 现场更新、地图功能和设备工具整合到同一处。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardOverviewFeature1 => '在主消息标签页中发送私聊消息、房间消息和频道消息。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardOverviewFeature2 => '通过 mesh 网络共享 SAR 标记、地图绘图、语音片段和图像。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardOverviewFeature3 => '通过 BLE 或 TCP 连接,然后直接在应用中管理配套电台。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMessagingTitle => '消息与现场报告';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMessagingDescription => '这里的消息不只是纯文本。应用已经支持多种操作载荷和传输流程。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMessagingFeature1 => '通过一个编辑器发送私聊消息、房间消息和频道流量。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMessagingFeature2 => '为常见现场报告创建 SAR 更新和可复用的 SAR 模板。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMessagingFeature3 => '传输语音会话和图像,并在界面中显示进度和空口时间估算。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardConnectDeviceTitle => '连接设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardConnectDeviceDescription =>
|
||||||
|
'连接你的 MeshCore 电台,选择名称,并在继续前应用无线电预设。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardSetupBadge => '设置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardOverviewBadge => '概览';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String wizardConnectedToDevice(String deviceName) {
|
||||||
|
return '已连接到 $deviceName';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNoDeviceConnected => '尚未连接设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardSkipForNow => '暂时跳过';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardDeviceNameLabel => '设备名称';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardDeviceNameHelp => '此名称会广播给其他 MeshCore 用户。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardConfigRegionLabel => '配置区域';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardConfigRegionHelp =>
|
||||||
|
'使用完整的官方 MeshCore 预设列表。默认值为 EU/UK (Narrow)。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardPresetNote1 => '请确认所选预设符合你所在地区的无线电规定。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardPresetNote2 => '该列表与 MeshCore 配置工具的官方预设源保持一致。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardPresetNote3 => '在引导设置中默认保持选中 EU/UK (Narrow)。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardSaving => '正在保存...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardSaveAndContinue => '保存并继续';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardEnterDeviceName => '继续前请输入设备名称。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String wizardDeviceSetupSaved(String deviceName, String presetName) {
|
||||||
|
return '已保存 $deviceName,配置为 $presetName。';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNetworkTitle => '联系人、房间和中继器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNetworkDescription => '联系人标签页会整理你发现的网络以及随着时间学习到的路由。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNetworkFeature1 => '在一个列表中查看团队成员、中继器、房间、频道和待处理广播。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNetworkFeature2 => '当连接变得混乱时,使用智能 ping、房间登录、已学习路径和路由重置工具。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardNetworkFeature3 => '无需离开应用即可创建频道并管理网络目的地。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMapOpsTitle => '地图、轨迹与共享几何';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMapOpsDescription => '应用地图直接与消息、跟踪和 SAR 叠加层关联,而不是单独的查看器。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMapOpsFeature1 => '在地图上跟踪你自己的位置、队友位置和移动轨迹。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMapOpsFeature2 => '打开消息中的绘图,内联预览,并在需要时将其从地图上移除。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardMapOpsFeature3 => '使用中继器地图视图和共享叠加层来了解现场网络覆盖范围。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardToolsTitle => '消息之外的工具';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardToolsDescription => '这里不只有四个主标签页。应用还包含配置、诊断和可选的传感器工作流。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardToolsFeature1 => '打开设备配置以更改无线电设置、遥测、TX 功率和配套设备详情。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardToolsFeature2 => '当你需要受监控的传感器面板和快速刷新操作时,启用传感器标签页。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wizardToolsFeature3 => '在排查 mesh 网络问题时使用数据包日志、频谱扫描和开发者诊断。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get postConnectDiscoveryTitle => 'Discover repeaters now?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get postConnectDiscoveryDescription =>
|
||||||
|
'Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactInSensors => '在传感器中';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactAddToSensors => '添加到传感器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactSetPath => '设置路径';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactAddedToSensors(String contactName) {
|
||||||
|
return '已将 $contactName 添加到传感器';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactFailedToClearRoute(String error) {
|
||||||
|
return '清除路由失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactRouteCleared => '路由已清除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactRouteSet(String route) {
|
||||||
|
return '路由已设置:$route';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String contactFailedToSetRoute(String error) {
|
||||||
|
return '设置路由失败:$error';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rssi => 'RSSI';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get snr => 'SNR';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ackTimeout => 'ACK 超时';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get opcode => '操作码';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get payload => '有效载荷';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hops => '跳数';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hashSize => '哈希大小';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pathBytes => '路径字节';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectedPath => '选定路径';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get estimatedTx => '预计传输';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get senderToReceipt => '发送到接收';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get receivedCopies => '收到的副本';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retryCause => '重试原因';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retryMode => '重试模式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get retryResult => '重试结果';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get lastRetry => '最后重试';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rxPackets => 'RX 数据包';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get mesh => 'Mesh';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rate => '速率';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get window => '窗口';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get posttxDelay => '发送后延迟';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bandpass => '带通';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get bandpassFilterVoice => '带通语音滤波';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get frequency => '频率';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get australia => '澳大利亚';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get australiaNarrow => '澳大利亚(窄带)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get australiaQld => '澳大利亚:QLD';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get australiaSaWa => '澳大利亚:SA, WA';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get newZealand => '新西兰';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get newZealandNarrow => '新西兰(窄带)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get switzerland => '瑞士';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portugal433 => '葡萄牙 433';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get portugal868 => '葡萄牙 868';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get czechRepublicNarrow => '捷克(窄带)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get eu433mhzLongRange => '欧盟 433MHz(远距离)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get euukDeprecated => '欧盟/英国(已弃用)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get euukNarrow => '欧盟/英国(窄带)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get usacanadaRecommended => '美国/加拿大(推荐)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vietnamDeprecated => '越南(已弃用)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get vietnamNarrow => '越南(窄带)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get active => '活动';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get addContact => '添加联系人';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get all => '全部';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoResolve => '自动解析';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearAllLabel => '清除全部';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearRelays => '清除中继';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearFilters => '清除过滤器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearRoute => '清除路由';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearMessages => '清除消息';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearScale => '清除比例';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearDiscoveries => '清除发现';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearOnlineTraceDatabase => '清除在线追踪数据库';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearAllChannels => '清除所有频道';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearAllContacts => '清除所有联系人';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearChannels => '清除频道';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearContacts => '清除联系人';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearPathOnMaxRetry => '最大重试时清除路径';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get create => '创建';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get custom => '自定义';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get defaultValue => '默认';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get duplicate => '复制';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get editName => '编辑名称';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get open => '打开';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get paste => '粘贴';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get preview => '预览';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get remove => '移除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rename => '重命名';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get resolveAll => '解析全部';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get send => '发送';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sendAnyway => '仍然发送';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get share => '分享';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get shareContact => '分享联系人';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trace => '追踪';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get use => '使用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get useSelectedFrequency => '使用选定频率';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discovery => '发现';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverRepeaters => '发现中继器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverSensors => '发现传感器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get repeaterDiscoverySent => '中继器发现已发送';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sensorDiscoverySent => '传感器发现已发送';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearedPendingDiscoveries => '已清除待处理的发现。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoDiscovery => '自动发现';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableAutomaticAdding => '启用自动添加';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoaddRepeaters => '自动添加中继器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoaddRoomServers => '自动添加房间服务器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoaddSensors => '自动添加传感器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoaddUsers => '自动添加用户';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get overwriteOldestWhenFull => '满时覆盖最旧的';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get storage => '存储';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dangerZone => '危险区域';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get profiles => '配置文件';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get favourites => '收藏夹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sensors => '传感器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get others => '其他';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get gpsModule => 'GPS 模块';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get liveTraffic => '实时流量';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get repeatersMap => '中继器地图';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get spectrumScan => '频谱扫描';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get blePacketLogs => 'BLE 数据包日志';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onlineTraceDatabase => '在线追踪数据库';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get routePathByteSize => '路由路径字节大小';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get messageNotifications => '消息通知';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sarAlerts => 'SAR 警报';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoveryNotifications => '发现通知';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateNotifications => '更新通知';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get muteWhileAppIsOpen => '应用打开时静音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get disableContacts => '禁用联系人';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableSensorsTab => '启用传感器标签';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableProfiles => '启用配置文件';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoRouteRotation => '自动路由轮换';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nearestRepeaterFallback => '最近中继器回退';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteAllStoredMessageHistory => '删除所有存储的消息历史';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get messageFontSize => '消息字体大小';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get rotateMapWithHeading => '随航向旋转地图';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showMapDebugInfo => '显示地图调试信息';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get openMapInFullscreen => '全屏打开地图';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showSarMarkersLabel => '显示 SAR 标记';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get displaySarMarkersOnTheMainMap => '在主地图上显示 SAR 标记';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get showAllContactTrailsLabel => '显示所有联系人轨迹';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hideRepeatersOnMap => '在地图上隐藏中继器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setMapScale => '设置地图比例';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customMapScaleSaved => '自定义地图比例已保存';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceBitrate => '语音比特率';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceCompressor => '语音压缩器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get balancesQuietAndLoudSpeechLevels => '平衡安静和响亮的语音级别';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get voiceLimiter => '语音限制器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get preventsClippingPeaksBeforeEncoding => '防止编码前的削波';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get micAutoGain => '麦克风自动增益';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get letsTheRecorderAdjustInputLevel => '让录音器调整输入级别';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get echoCancellation => '回声消除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noiseSuppression => '噪音抑制';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get trimSilenceInVoiceMessages => '修剪语音消息中的静音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get compressor => '压缩器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get limiter => '限制器';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get autoGain => '自动增益';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get echoCancel => '回声';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noiseSuppress => '噪声';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get silenceTrim => '静音';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get maxImageSize => '最大图片大小';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get imageCompression => '图片压缩';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get grayscale => '灰度';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get ultraMode => '超级模式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fastPrivateGpsUpdates => '快速私有 GPS 更新';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get movementThreshold => '移动阈值';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fastGpsMovementThreshold => '快速 GPS 移动阈值';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fastGpsActiveuseInterval => '快速 GPS 活动使用间隔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get activeuseUpdateInterval => '活动使用更新间隔';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get repeatNearbyTraffic => '转发附近流量';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get relayThroughRepeatersAcrossTheMesh => '通过网格中的中继器转发';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get nearbyOnlyWithoutRepeaterFlooding => '仅附近,无中继器泛洪';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get multihop => '多跳';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get createProfile => '创建配置文件';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get renameProfile => '重命名配置文件';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get newProfile => '新配置文件';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get manageProfiles => '管理配置文件';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enableProfilesToStartManagingThem => '启用配置文件以开始管理它们。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get openMessage => '打开消息';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get jumpToTheRelatedSarMessage => '跳转到相关的 SAR 消息';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get removeSarMarker => '移除 SAR 标记';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pleaseSelectADestinationToSendSarMarker => '请选择目标以发送 SAR 标记';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sarMarkerBroadcastToPublicChannel => 'SAR 标记已广播到公共频道';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sarMarkerSentToRoom => 'SAR 标记已发送到房间';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get loadFromGallery => '从图库加载';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get replaceImage => '替换图片';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectFromGallery => '从图库选择';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get team => '团队';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get found => '已找到';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get staging => '集结区';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get object => '物体';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get quiet => '安静';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get moderate => '中等';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get busy => '繁忙';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get spectrumScanReturnedNoCandidateFrequencies => '频谱扫描未找到候选频率';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchMessages => '搜索消息';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sendImageFromGallery => '从图库发送图片';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get takePhoto => '拍照';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dmOnly => '仅私信';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allMessages => '所有消息';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sendToPublicChannel => '发送到公共频道?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get selectMarkerTypeAndDestination => '选择标记类型和目标';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDestinationsAvailableLabel => '没有可用的目标';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get image => '图片';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get format => '格式';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get dimensions => '尺寸';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get segments => '分段';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get transfers => '传输';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadedBy => '已下载';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveDiscoverySettings => '保存发现设置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get savePublicInfo => '保存公共信息';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get saveRadioSettings => '保存无线电设置';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get savePath => '保存路径';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wipeDeviceData => '擦除设备数据';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get wipeDevice => '擦除设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get destructiveDeviceActions => '破坏性设备操作。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chooseAPresetOrFinetuneCustomRadioSettings => '选择预设或微调自定义无线电设置。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get chooseTheNameAndLocationThisDeviceShares => '选择此设备共享的名称和位置。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get availableSpaceOnThisDevice => '此设备上的可用空间。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get used => '已使用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get total => '总计';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get renameValue => '重命名值';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get customizeFields => '自定义字段';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get livePreview => '实时预览';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get refreshSchedule => '刷新计划';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noResponse => '无响应';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get refreshing => '刷新中';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get unavailable => '不可用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get pickARelayOrNodeToWatchInSensors => '选择要在传感器中监视的中继或节点。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get publicKeyLabel => '公钥';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get alreadyInContacts => '已在联系人中';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get connectToADeviceBeforeAddingContacts => '添加联系人前请连接到设备';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get fromContacts => '来自联系人';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onlineOnly => '仅在线';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get inBoth => '两者中';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get source => '来源';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get manualRouteEdit => '手动路由编辑';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get observedMeshRoute => '观察到的网格路由';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get allMessagesCleared => '所有消息已清除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onlineTraceDatabaseCleared => '在线追踪数据库已清除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get packetLogsCleared => '数据包日志已清除';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get hexDataCopiedToClipboard => '十六进制数据已复制到剪贴板';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get developerModeEnabled => '开发者模式已启用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get developerModeDisabled => '开发者模式已禁用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clipboardIsEmpty => '剪贴板为空';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactImported => '联系人已导入';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get contactLinkCopiedToClipboard => '联系人链接已复制到剪贴板';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get failedToExportContact => '导出联系人失败';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noLogsToExport => '没有日志可导出';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get exportAsCsv => '导出为 CSV';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get exportAsText => '导出为文本';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get receivedRfc3339 => '收到 (RFC3339)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get buildTime => '构建时间';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get downloadUrlNotAvailable => '下载链接不可用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get cannotOpenDownloadUrl => '无法打开下载链接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateCheckIsOnlyAvailableOnAndroid => '更新检查仅在 Android 上可用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get youAreRunningTheLatestVersion => '您正在使用最新版本';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get updateAvailableButDownloadUrlNotFound => '有更新可用但未找到下载链接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get startTictactoe => '开始井字棋';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tictactoeUnavailable => '井字棋不可用';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tictactoeOpponentUnknown => '井字棋:对手未知';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get tictactoeWaitingForStart => '井字棋:等待开始';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get acceptsShareLinks => '接受分享链接';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get supportsRawHex => '支持原始十六进制';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clipboardfriendly => '剪贴板友好';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get captured => '已捕获';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get size => '大小';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noCustomChannelsToClear => '没有自定义频道可清除。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noDeviceContactsToClear => '没有设备联系人可清除。';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get setRegionScope => 'Set region scope';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionScope => 'Region scope';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionScopeNone => 'None (global)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get clearRegionScope => 'Clear scope';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionScopeWarning =>
|
||||||
|
'Only repeaters allowing this region will forward.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoverRegions => 'Discover from repeaters';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get discoveringRegions => 'Discovering regions...';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get enterRegionName => 'Region name (e.g. auckland)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get noRegionsFound => 'No regions found on this repeater.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String regionScopeSet(String name) {
|
||||||
|
return 'Region scope set to $name';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get regionScopeCleared => 'Region scope cleared';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2276,5 +2276,304 @@
|
|||||||
"voiceUnavailable": "Głos jest obecnie niedostępny",
|
"voiceUnavailable": "Głos jest obecnie niedostępny",
|
||||||
"@voiceUnavailable": {},
|
"@voiceUnavailable": {},
|
||||||
"requestingVoice": "Pobieranie głosu",
|
"requestingVoice": "Pobieranie głosu",
|
||||||
"@requestingVoice": {}
|
"@requestingVoice": {},
|
||||||
|
"device": "urządzenie",
|
||||||
|
"change": "Zmień",
|
||||||
|
"wizardOverviewDescription": "Ta aplikacja łączy wiadomości MeshCore, terenowe aktualizacje SAR, mapy i narzędzia urządzenia w jednym miejscu.",
|
||||||
|
"wizardOverviewFeature1": "Wysyłaj wiadomości bezpośrednie, wpisy pokojowe i wiadomości kanałowe z głównej karty Wiadomości.",
|
||||||
|
"wizardOverviewFeature2": "Udostępniaj znaczniki SAR, rysunki mapy, klipy głosowe i obrazy przez sieć mesh.",
|
||||||
|
"wizardOverviewFeature3": "Połącz się przez BLE lub TCP, a następnie zarządzaj radiem towarzyszącym bezpośrednio z aplikacji.",
|
||||||
|
"wizardMessagingTitle": "Wiadomości i raporty terenowe",
|
||||||
|
"wizardMessagingDescription": "Wiadomości to tutaj coś więcej niż zwykły tekst. Aplikacja obsługuje już kilka typów danych operacyjnych i przepływów transferu.",
|
||||||
|
"wizardMessagingFeature1": "Wysyłaj wiadomości bezpośrednie, wpisy pokojowe i ruch kanałowy z jednego edytora.",
|
||||||
|
"wizardMessagingFeature2": "Twórz aktualizacje SAR i wielokrotnego użytku szablony SAR do typowych raportów terenowych.",
|
||||||
|
"wizardMessagingFeature3": "Przesyłaj sesje głosowe i obrazy z postępem i szacowanym czasem transmisji w interfejsie.",
|
||||||
|
"wizardConnectDeviceTitle": "Połącz urządzenie",
|
||||||
|
"wizardConnectDeviceDescription": "Podłącz radio MeshCore, wybierz nazwę i zastosuj profil radiowy przed kontynuacją.",
|
||||||
|
"wizardSetupBadge": "Konfiguracja",
|
||||||
|
"wizardOverviewBadge": "Przegląd",
|
||||||
|
"wizardConnectedToDevice": "Połączono z {deviceName}",
|
||||||
|
"wizardNoDeviceConnected": "Brak podłączonego urządzenia",
|
||||||
|
"wizardSkipForNow": "Pomiń na razie",
|
||||||
|
"wizardDeviceNameLabel": "Nazwa urządzenia",
|
||||||
|
"wizardDeviceNameHelp": "Ta nazwa jest rozgłaszana innym użytkownikom MeshCore.",
|
||||||
|
"wizardConfigRegionLabel": "Region konfiguracji",
|
||||||
|
"wizardConfigRegionHelp": "Używa pełnej oficjalnej listy presetów MeshCore. Domyślnie wybrane jest EU/UK (Narrow).",
|
||||||
|
"wizardPresetNote1": "Upewnij się, że wybrany preset jest zgodny z lokalnymi przepisami radiowymi.",
|
||||||
|
"wizardPresetNote2": "Lista odpowiada oficjalnemu źródłu presetów narzędzia konfiguracji MeshCore.",
|
||||||
|
"wizardPresetNote3": "EU/UK (Narrow) pozostaje domyślnie wybrane podczas wdrożenia.",
|
||||||
|
"wizardSaving": "Zapisywanie...",
|
||||||
|
"wizardSaveAndContinue": "Zapisz i kontynuuj",
|
||||||
|
"wizardEnterDeviceName": "Wprowadź nazwę urządzenia przed kontynuacją.",
|
||||||
|
"wizardDeviceSetupSaved": "Zapisano {deviceName} z ustawieniem {presetName}.",
|
||||||
|
"wizardNetworkTitle": "Kontakty, pokoje i repeatery",
|
||||||
|
"wizardNetworkDescription": "Karta Kontakty porządkuje sieć, którą odkrywasz, oraz trasy, których uczysz się z czasem.",
|
||||||
|
"wizardNetworkFeature1": "Przeglądaj członków zespołu, repeatery, pokoje, kanały i oczekujące ogłoszenia na jednej liście.",
|
||||||
|
"wizardNetworkFeature2": "Używaj smart ping, logowania do pokoi, poznanych ścieżek i narzędzi resetowania tras, gdy łączność staje się chaotyczna.",
|
||||||
|
"wizardNetworkFeature3": "Twórz kanały i zarządzaj celami sieciowymi bez opuszczania aplikacji.",
|
||||||
|
"wizardMapOpsTitle": "Mapa, ślady i współdzielona geometria",
|
||||||
|
"wizardMapOpsDescription": "Mapa aplikacji jest bezpośrednio powiązana z wiadomościami, śledzeniem i nakładkami SAR zamiast być osobnym podglądem.",
|
||||||
|
"wizardMapOpsFeature1": "Śledź własną pozycję, lokalizacje członków zespołu i ślady ruchu na mapie.",
|
||||||
|
"wizardMapOpsFeature2": "Otwieraj rysunki z wiadomości, podglądaj je w miejscu i usuwaj z mapy w razie potrzeby.",
|
||||||
|
"wizardMapOpsFeature3": "Korzystaj z widoków map repeaterów i współdzielonych nakładek, aby zrozumieć zasięg sieci w terenie.",
|
||||||
|
"wizardToolsTitle": "Narzędzia poza wiadomościami",
|
||||||
|
"wizardToolsDescription": "Jest tu więcej niż cztery główne karty. Aplikacja obejmuje także konfigurację, diagnostykę i opcjonalne przepływy czujników.",
|
||||||
|
"wizardToolsFeature1": "Otwórz konfigurację urządzenia, aby zmienić ustawienia radia, telemetrię, moc TX i szczegóły urządzenia towarzyszącego.",
|
||||||
|
"wizardToolsFeature2": "Włącz kartę Czujniki, gdy chcesz mieć pulpity monitorowanych czujników i szybkie odświeżanie.",
|
||||||
|
"wizardToolsFeature3": "Używaj logów pakietów, skanowania widma i diagnostyki deweloperskiej podczas rozwiązywania problemów z mesh.",
|
||||||
|
"contactInSensors": "W Czujnikach",
|
||||||
|
"contactAddToSensors": "Dodaj do Czujników",
|
||||||
|
"contactSetPath": "Ustaw ścieżkę",
|
||||||
|
"contactAddedToSensors": "{contactName} dodano do Czujników",
|
||||||
|
"contactFailedToClearRoute": "Nie udało się wyczyścić trasy: {error}",
|
||||||
|
"contactRouteCleared": "Trasa wyczyszczona",
|
||||||
|
"contactRouteSet": "Ustawiono trasę: {route}",
|
||||||
|
"contactFailedToSetRoute": "Nie udało się ustawić trasy: {error}",
|
||||||
|
"rssi": "RSSI",
|
||||||
|
"snr": "SNR",
|
||||||
|
"ackTimeout": "Limit czasu ACK",
|
||||||
|
"opcode": "Opcode",
|
||||||
|
"payload": "Ładunek",
|
||||||
|
"hops": "Skoki",
|
||||||
|
"hashSize": "Rozmiar hash",
|
||||||
|
"pathBytes": "Bajty ścieżki",
|
||||||
|
"selectedPath": "Wybrana ścieżka",
|
||||||
|
"estimatedTx": "Szacowane nadawanie",
|
||||||
|
"senderToReceipt": "Od nadawcy do odbioru",
|
||||||
|
"receivedCopies": "Otrzymane kopie",
|
||||||
|
"retryCause": "Przyczyna ponowienia",
|
||||||
|
"retryMode": "Tryb ponowienia",
|
||||||
|
"retryResult": "Wynik ponowienia",
|
||||||
|
"lastRetry": "Ostatnia próba",
|
||||||
|
"rxPackets": "Pakiety RX",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Szybkość",
|
||||||
|
"window": "Okno",
|
||||||
|
"posttxDelay": "Opóźnienie po nadawaniu",
|
||||||
|
"bandpass": "Pasmowy",
|
||||||
|
"bandpassFilterVoice": "Filtr pasmowy głosu",
|
||||||
|
"frequency": "Częstotliwość",
|
||||||
|
"australia": "Australia",
|
||||||
|
"australiaNarrow": "Australia (wąski)",
|
||||||
|
"australiaQld": "Australia: QLD",
|
||||||
|
"australiaSaWa": "Australia: SA, WA",
|
||||||
|
"newZealand": "Nowa Zelandia",
|
||||||
|
"newZealandNarrow": "Nowa Zelandia (wąski)",
|
||||||
|
"switzerland": "Szwajcaria",
|
||||||
|
"portugal433": "Portugalia 433",
|
||||||
|
"portugal868": "Portugalia 868",
|
||||||
|
"czechRepublicNarrow": "Czechy (wąski)",
|
||||||
|
"eu433mhzLongRange": "EU 433MHz (daleki zasięg)",
|
||||||
|
"euukDeprecated": "EU/UK (przestarzały)",
|
||||||
|
"euukNarrow": "EU/UK (wąski)",
|
||||||
|
"usacanadaRecommended": "USA/Kanada (zalecany)",
|
||||||
|
"vietnamDeprecated": "Wietnam (przestarzały)",
|
||||||
|
"vietnamNarrow": "Wietnam (wąski)",
|
||||||
|
"active": "Aktywny",
|
||||||
|
"addContact": "Dodaj kontakt",
|
||||||
|
"all": "Wszystko",
|
||||||
|
"autoResolve": "Automatyczne rozwiązanie",
|
||||||
|
"clearAllLabel": "Wyczyść wszystko",
|
||||||
|
"clearRelays": "Wyczyść przekaźniki",
|
||||||
|
"clearFilters": "Wyczyść filtry",
|
||||||
|
"clearRoute": "Wyczyść trasę",
|
||||||
|
"clearMessages": "Wyczyść wiadomości",
|
||||||
|
"clearScale": "Wyczyść skalę",
|
||||||
|
"clearDiscoveries": "Wyczyść odkrycia",
|
||||||
|
"clearOnlineTraceDatabase": "Wyczyść bazę śladów",
|
||||||
|
"clearAllChannels": "Wyczyść wszystkie kanały",
|
||||||
|
"clearAllContacts": "Wyczyść wszystkie kontakty",
|
||||||
|
"clearChannels": "Wyczyść kanały",
|
||||||
|
"clearContacts": "Wyczyść kontakty",
|
||||||
|
"clearPathOnMaxRetry": "Wyczyść ścieżkę przy maks. próbie",
|
||||||
|
"create": "Utwórz",
|
||||||
|
"custom": "Niestandardowy",
|
||||||
|
"defaultValue": "Domyślny",
|
||||||
|
"duplicate": "Duplikuj",
|
||||||
|
"editName": "Edytuj nazwę",
|
||||||
|
"open": "Otwórz",
|
||||||
|
"paste": "Wklej",
|
||||||
|
"preview": "Podgląd",
|
||||||
|
"remove": "Usuń",
|
||||||
|
"rename": "Zmień nazwę",
|
||||||
|
"resolveAll": "Rozwiąż wszystko",
|
||||||
|
"send": "Wyślij",
|
||||||
|
"sendAnyway": "Wyślij mimo to",
|
||||||
|
"share": "Udostępnij",
|
||||||
|
"shareContact": "Udostępnij kontakt",
|
||||||
|
"trace": "Śledzenie",
|
||||||
|
"use": "Użyj",
|
||||||
|
"useSelectedFrequency": "Użyj wybranej częstotliwości",
|
||||||
|
"discovery": "Wykrywanie",
|
||||||
|
"discoverRepeaters": "Wykryj przekaźniki",
|
||||||
|
"discoverSensors": "Wykryj czujniki",
|
||||||
|
"repeaterDiscoverySent": "Wykrywanie przekaźników wysłane",
|
||||||
|
"sensorDiscoverySent": "Wykrywanie czujników wysłane",
|
||||||
|
"clearedPendingDiscoveries": "Wyczyszczono oczekujące odkrycia.",
|
||||||
|
"autoDiscovery": "Automatyczne wykrywanie",
|
||||||
|
"enableAutomaticAdding": "Włącz automatyczne dodawanie",
|
||||||
|
"autoaddRepeaters": "Automatycznie dodaj przekaźniki",
|
||||||
|
"autoaddRoomServers": "Automatycznie dodaj serwery pokoi",
|
||||||
|
"autoaddSensors": "Automatycznie dodaj czujniki",
|
||||||
|
"autoaddUsers": "Automatycznie dodaj użytkowników",
|
||||||
|
"overwriteOldestWhenFull": "Nadpisz najstarsze gdy pełne",
|
||||||
|
"storage": "Pamięć",
|
||||||
|
"dangerZone": "Strefa zagrożenia",
|
||||||
|
"profiles": "Profile",
|
||||||
|
"favourites": "Ulubione",
|
||||||
|
"sensors": "Czujniki",
|
||||||
|
"others": "Inne",
|
||||||
|
"gpsModule": "Moduł GPS",
|
||||||
|
"liveTraffic": "Ruch na żywo",
|
||||||
|
"repeatersMap": "Mapa przekaźników",
|
||||||
|
"spectrumScan": "Skanowanie spektrum",
|
||||||
|
"blePacketLogs": "Logi pakietów BLE",
|
||||||
|
"onlineTraceDatabase": "Baza śladów",
|
||||||
|
"routePathByteSize": "Rozmiar ścieżki w bajtach",
|
||||||
|
"messageNotifications": "Powiadomienia o wiadomościach",
|
||||||
|
"sarAlerts": "Alerty SAR",
|
||||||
|
"discoveryNotifications": "Powiadomienia o wykrywaniu",
|
||||||
|
"updateNotifications": "Powiadomienia o aktualizacjach",
|
||||||
|
"muteWhileAppIsOpen": "Wycisz gdy aplikacja jest otwarta",
|
||||||
|
"disableContacts": "Wyłącz kontakty",
|
||||||
|
"enableSensorsTab": "Włącz kartę Czujniki",
|
||||||
|
"enableProfiles": "Włącz profile",
|
||||||
|
"autoRouteRotation": "Automatyczna rotacja trasy",
|
||||||
|
"nearestRepeaterFallback": "Najbliższy przekaźnik awaryjny",
|
||||||
|
"deleteAllStoredMessageHistory": "Usuń całą historię wiadomości",
|
||||||
|
"messageFontSize": "Rozmiar czcionki wiadomości",
|
||||||
|
"rotateMapWithHeading": "Obracaj mapę z kierunkiem",
|
||||||
|
"showMapDebugInfo": "Pokaż info debugowania mapy",
|
||||||
|
"openMapInFullscreen": "Otwórz mapę na pełnym ekranie",
|
||||||
|
"showSarMarkersLabel": "Pokaż znaczniki SAR",
|
||||||
|
"displaySarMarkersOnTheMainMap": "Wyświetlaj znaczniki SAR na głównej mapie",
|
||||||
|
"showAllContactTrailsLabel": "Pokaż wszystkie ślady kontaktów",
|
||||||
|
"hideRepeatersOnMap": "Ukryj przekaźniki na mapie",
|
||||||
|
"setMapScale": "Ustaw skalę mapy",
|
||||||
|
"customMapScaleSaved": "Niestandardowa skala mapy zapisana",
|
||||||
|
"voiceBitrate": "Bitrate głosu",
|
||||||
|
"voiceCompressor": "Kompresor głosu",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Wyrównuje ciche i głośne poziomy mowy",
|
||||||
|
"voiceLimiter": "Limiter głosu",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Zapobiega przycinaniu szczytów przed kodowaniem",
|
||||||
|
"micAutoGain": "Automatyczne wzmocnienie mikrofonu",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Pozwala nagrywarkowi dostosować poziom wejścia",
|
||||||
|
"echoCancellation": "Usuwanie echa",
|
||||||
|
"noiseSuppression": "Tłumienie szumu",
|
||||||
|
"trimSilenceInVoiceMessages": "Przytnij ciszę w wiadomościach głosowych",
|
||||||
|
"compressor": "Kompresor",
|
||||||
|
"limiter": "Limiter",
|
||||||
|
"autoGain": "Automatyczne wzmocnienie",
|
||||||
|
"echoCancel": "Echo",
|
||||||
|
"noiseSuppress": "Szum",
|
||||||
|
"silenceTrim": "Cisza",
|
||||||
|
"maxImageSize": "Maksymalny rozmiar obrazu",
|
||||||
|
"imageCompression": "Kompresja obrazu",
|
||||||
|
"grayscale": "Skala szarości",
|
||||||
|
"ultraMode": "Tryb ultra",
|
||||||
|
"fastPrivateGpsUpdates": "Szybkie prywatne aktualizacje GPS",
|
||||||
|
"movementThreshold": "Próg ruchu",
|
||||||
|
"fastGpsMovementThreshold": "Próg ruchu szybkiego GPS",
|
||||||
|
"fastGpsActiveuseInterval": "Interwał aktywnego użycia szybkiego GPS",
|
||||||
|
"activeuseUpdateInterval": "Interwał aktualizacji przy aktywnym użyciu",
|
||||||
|
"repeatNearbyTraffic": "Powtarzaj pobliski ruch",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Przekazuj przez przekaźniki w sieci",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Tylko w pobliżu, bez zalewania przekaźników",
|
||||||
|
"multihop": "Wieloskokowy",
|
||||||
|
"createProfile": "Utwórz profil",
|
||||||
|
"renameProfile": "Zmień nazwę profilu",
|
||||||
|
"newProfile": "Nowy profil",
|
||||||
|
"manageProfiles": "Zarządzaj profilami",
|
||||||
|
"enableProfilesToStartManagingThem": "Włącz profile, aby zacząć nimi zarządzać.",
|
||||||
|
"openMessage": "Otwórz wiadomość",
|
||||||
|
"jumpToTheRelatedSarMessage": "Przejdź do powiązanej wiadomości SAR",
|
||||||
|
"removeSarMarker": "Usuń znacznik SAR",
|
||||||
|
"pleaseSelectADestinationToSendSarMarker": "Wybierz cel wysłania znacznika SAR",
|
||||||
|
"sarMarkerBroadcastToPublicChannel": "Znacznik SAR wysłany na kanał publiczny",
|
||||||
|
"sarMarkerSentToRoom": "Znacznik SAR wysłany do pokoju",
|
||||||
|
"loadFromGallery": "Załaduj z galerii",
|
||||||
|
"replaceImage": "Zamień obraz",
|
||||||
|
"selectFromGallery": "Wybierz z galerii",
|
||||||
|
"team": "Zespół",
|
||||||
|
"found": "Znaleziono",
|
||||||
|
"staging": "Punkt zbiórki",
|
||||||
|
"object": "Obiekt",
|
||||||
|
"quiet": "Cicho",
|
||||||
|
"moderate": "Umiarkowane",
|
||||||
|
"busy": "Zajęty",
|
||||||
|
"spectrumScanReturnedNoCandidateFrequencies": "Skanowanie spektrum nie znalazło częstotliwości kandydatów",
|
||||||
|
"searchMessages": "Szukaj wiadomości",
|
||||||
|
"sendImageFromGallery": "Wyślij obraz z galerii",
|
||||||
|
"takePhoto": "Zrób zdjęcie",
|
||||||
|
"dmOnly": "Tylko wiadomość bezpośrednia",
|
||||||
|
"allMessages": "Wszystkie wiadomości",
|
||||||
|
"sendToPublicChannel": "Wysłać na kanał publiczny?",
|
||||||
|
"selectMarkerTypeAndDestination": "Wybierz typ znacznika i cel",
|
||||||
|
"noDestinationsAvailableLabel": "Brak dostępnych celów",
|
||||||
|
"image": "Obraz",
|
||||||
|
"format": "Format",
|
||||||
|
"dimensions": "Wymiary",
|
||||||
|
"segments": "Segmenty",
|
||||||
|
"transfers": "Transfery",
|
||||||
|
"downloadedBy": "Pobrane przez",
|
||||||
|
"saveDiscoverySettings": "Zapisz ustawienia wykrywania",
|
||||||
|
"savePublicInfo": "Zapisz informacje publiczne",
|
||||||
|
"saveRadioSettings": "Zapisz ustawienia radia",
|
||||||
|
"savePath": "Zapisz ścieżkę",
|
||||||
|
"wipeDeviceData": "Wyczyść dane urządzenia",
|
||||||
|
"wipeDevice": "Wyczyść urządzenie",
|
||||||
|
"destructiveDeviceActions": "Destrukcyjne akcje na urządzeniu.",
|
||||||
|
"chooseAPresetOrFinetuneCustomRadioSettings": "Wybierz ustawienie wstępne lub dostosuj ustawienia radia.",
|
||||||
|
"chooseTheNameAndLocationThisDeviceShares": "Wybierz nazwę i lokalizację udostępnianą przez to urządzenie.",
|
||||||
|
"availableSpaceOnThisDevice": "Dostępne miejsce na tym urządzeniu.",
|
||||||
|
"used": "Użyto",
|
||||||
|
"total": "Razem",
|
||||||
|
"renameValue": "Zmień nazwę wartości",
|
||||||
|
"customizeFields": "Dostosuj pola",
|
||||||
|
"livePreview": "Podgląd na żywo",
|
||||||
|
"refreshSchedule": "Harmonogram odświeżania",
|
||||||
|
"noResponse": "Brak odpowiedzi",
|
||||||
|
"refreshing": "Odświeżanie",
|
||||||
|
"unavailable": "Niedostępny",
|
||||||
|
"pickARelayOrNodeToWatchInSensors": "Wybierz przekaźnik lub węzeł do obserwacji.",
|
||||||
|
"publicKeyLabel": "Klucz publiczny",
|
||||||
|
"alreadyInContacts": "Już w kontaktach",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Połącz się z urządzeniem przed dodaniem kontaktów",
|
||||||
|
"fromContacts": "Z kontaktów",
|
||||||
|
"onlineOnly": "Tylko online",
|
||||||
|
"inBoth": "W obu",
|
||||||
|
"source": "Źródło",
|
||||||
|
"manualRouteEdit": "Ręczna edycja trasy",
|
||||||
|
"observedMeshRoute": "Obserwowana trasa sieci",
|
||||||
|
"allMessagesCleared": "Wszystkie wiadomości wyczyszczone",
|
||||||
|
"onlineTraceDatabaseCleared": "Baza śladów wyczyszczona",
|
||||||
|
"packetLogsCleared": "Logi pakietów wyczyszczone",
|
||||||
|
"hexDataCopiedToClipboard": "Dane hex skopiowane do schowka",
|
||||||
|
"developerModeEnabled": "Tryb programisty włączony",
|
||||||
|
"developerModeDisabled": "Tryb programisty wyłączony",
|
||||||
|
"clipboardIsEmpty": "Schowek jest pusty",
|
||||||
|
"contactImported": "Kontakt zaimportowany",
|
||||||
|
"contactLinkCopiedToClipboard": "Link kontaktu skopiowany do schowka",
|
||||||
|
"failedToExportContact": "Nie udało się wyeksportować kontaktu",
|
||||||
|
"noLogsToExport": "Brak logów do eksportu",
|
||||||
|
"exportAsCsv": "Eksportuj jako CSV",
|
||||||
|
"exportAsText": "Eksportuj jako tekst",
|
||||||
|
"receivedRfc3339": "Odebrano (RFC3339)",
|
||||||
|
"buildTime": "Czas budowania",
|
||||||
|
"downloadUrlNotAvailable": "URL pobierania niedostępny",
|
||||||
|
"cannotOpenDownloadUrl": "Nie można otworzyć URL pobierania",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "Sprawdzanie aktualizacji dostępne tylko na Androidzie",
|
||||||
|
"youAreRunningTheLatestVersion": "Używasz najnowszej wersji",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "Aktualizacja dostępna, ale URL pobierania nie znaleziony",
|
||||||
|
"startTictactoe": "Rozpocznij Tic-Tac-Toe",
|
||||||
|
"tictactoeUnavailable": "Tic-Tac-Toe niedostępny",
|
||||||
|
"tictactoeOpponentUnknown": "Tic-Tac-Toe: przeciwnik nieznany",
|
||||||
|
"tictactoeWaitingForStart": "Tic-Tac-Toe: oczekiwanie na start",
|
||||||
|
"acceptsShareLinks": "Akceptuje udostępnione linki",
|
||||||
|
"supportsRawHex": "Obsługuje surowy hex",
|
||||||
|
"clipboardfriendly": "Przyjazne dla schowka",
|
||||||
|
"captured": "Przechwycone",
|
||||||
|
"size": "Rozmiar",
|
||||||
|
"noCustomChannelsToClear": "Brak niestandardowych kanałów do wyczyszczenia.",
|
||||||
|
"noDeviceContactsToClear": "Brak kontaktów urządzenia do wyczyszczenia."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2276,5 +2276,304 @@
|
|||||||
"voiceUnavailable": "A voz não está disponível no momento",
|
"voiceUnavailable": "A voz não está disponível no momento",
|
||||||
"@voiceUnavailable": {},
|
"@voiceUnavailable": {},
|
||||||
"requestingVoice": "Solicitando voz",
|
"requestingVoice": "Solicitando voz",
|
||||||
"@requestingVoice": {}
|
"@requestingVoice": {},
|
||||||
|
"device": "dispositivo",
|
||||||
|
"change": "Alterar",
|
||||||
|
"wizardOverviewDescription": "Este aplicativo reúne mensagens MeshCore, atualizações SAR em campo, mapas e ferramentas do dispositivo em um só lugar.",
|
||||||
|
"wizardOverviewFeature1": "Envie mensagens diretas, publicações em salas e mensagens de canal pela guia principal Mensagens.",
|
||||||
|
"wizardOverviewFeature2": "Compartilhe marcadores SAR, desenhos de mapa, clipes de voz e imagens pela malha.",
|
||||||
|
"wizardOverviewFeature3": "Conecte-se via BLE ou TCP e depois gerencie o rádio complementar dentro do aplicativo.",
|
||||||
|
"wizardMessagingTitle": "Mensagens e relatórios de campo",
|
||||||
|
"wizardMessagingDescription": "Aqui, as mensagens são mais do que texto simples. O aplicativo já oferece suporte a várias cargas operacionais e fluxos de transferência.",
|
||||||
|
"wizardMessagingFeature1": "Envie mensagens diretas, publicações em salas e tráfego de canal a partir de um único compositor.",
|
||||||
|
"wizardMessagingFeature2": "Crie atualizações SAR e modelos SAR reutilizáveis para relatórios de campo comuns.",
|
||||||
|
"wizardMessagingFeature3": "Transfira sessões de voz e imagens com progresso e estimativas de tempo de transmissão na interface.",
|
||||||
|
"wizardConnectDeviceTitle": "Conectar dispositivo",
|
||||||
|
"wizardConnectDeviceDescription": "Conecte seu rádio MeshCore, escolha um nome e aplique uma predefinição de rádio antes de continuar.",
|
||||||
|
"wizardSetupBadge": "Configuração",
|
||||||
|
"wizardOverviewBadge": "Visão geral",
|
||||||
|
"wizardConnectedToDevice": "Conectado a {deviceName}",
|
||||||
|
"wizardNoDeviceConnected": "Nenhum dispositivo conectado ainda",
|
||||||
|
"wizardSkipForNow": "Ignorar por enquanto",
|
||||||
|
"wizardDeviceNameLabel": "Nome do dispositivo",
|
||||||
|
"wizardDeviceNameHelp": "Esse nome é anunciado para outros usuários do MeshCore.",
|
||||||
|
"wizardConfigRegionLabel": "Região de configuração",
|
||||||
|
"wizardConfigRegionHelp": "Usa a lista oficial completa de predefinições do MeshCore. O padrão é EU/UK (Narrow).",
|
||||||
|
"wizardPresetNote1": "Certifique-se de que a predefinição selecionada corresponda às regulamentações locais de rádio.",
|
||||||
|
"wizardPresetNote2": "A lista corresponde ao feed oficial de predefinições da ferramenta de configuração do MeshCore.",
|
||||||
|
"wizardPresetNote3": "EU/UK (Narrow) permanece selecionado por padrão durante a configuração inicial.",
|
||||||
|
"wizardSaving": "Salvando...",
|
||||||
|
"wizardSaveAndContinue": "Salvar e continuar",
|
||||||
|
"wizardEnterDeviceName": "Digite um nome para o dispositivo antes de continuar.",
|
||||||
|
"wizardDeviceSetupSaved": "{deviceName} salvo com {presetName}.",
|
||||||
|
"wizardNetworkTitle": "Contatos, salas e repetidores",
|
||||||
|
"wizardNetworkDescription": "A guia Contatos organiza a rede que você descobre e as rotas aprendidas ao longo do tempo.",
|
||||||
|
"wizardNetworkFeature1": "Revise membros da equipe, repetidores, salas, canais e anúncios pendentes em uma única lista.",
|
||||||
|
"wizardNetworkFeature2": "Use ping inteligente, login em salas, caminhos aprendidos e ferramentas de redefinição de rota quando a conectividade ficar confusa.",
|
||||||
|
"wizardNetworkFeature3": "Crie canais e gerencie destinos de rede sem sair do aplicativo.",
|
||||||
|
"wizardMapOpsTitle": "Mapa, trilhas e geometria compartilhada",
|
||||||
|
"wizardMapOpsDescription": "O mapa do aplicativo está ligado diretamente às mensagens, ao rastreamento e às sobreposições SAR, em vez de ser um visualizador separado.",
|
||||||
|
"wizardMapOpsFeature1": "Acompanhe sua própria posição, as localizações da equipe e as trilhas de movimento no mapa.",
|
||||||
|
"wizardMapOpsFeature2": "Abra desenhos de mensagens, visualize-os em linha e remova-os do mapa quando necessário.",
|
||||||
|
"wizardMapOpsFeature3": "Use vistas de mapa de repetidores e sobreposições compartilhadas para entender o alcance da rede em campo.",
|
||||||
|
"wizardToolsTitle": "Ferramentas além das mensagens",
|
||||||
|
"wizardToolsDescription": "Há mais aqui do que as quatro guias principais. O aplicativo também inclui configuração, diagnóstico e fluxos opcionais de sensores.",
|
||||||
|
"wizardToolsFeature1": "Abra a configuração do dispositivo para alterar ajustes de rádio, telemetria, potência TX e detalhes do equipamento complementar.",
|
||||||
|
"wizardToolsFeature2": "Ative a guia Sensores quando quiser painéis monitorados e ações de atualização rápida.",
|
||||||
|
"wizardToolsFeature3": "Use logs de pacotes, varredura de espectro e diagnóstico de desenvolvedor ao solucionar problemas da malha.",
|
||||||
|
"contactInSensors": "Nos Sensores",
|
||||||
|
"contactAddToSensors": "Adicionar aos Sensores",
|
||||||
|
"contactSetPath": "Definir rota",
|
||||||
|
"contactAddedToSensors": "{contactName} adicionado a Sensores",
|
||||||
|
"contactFailedToClearRoute": "Falha ao limpar a rota: {error}",
|
||||||
|
"contactRouteCleared": "Rota limpa",
|
||||||
|
"contactRouteSet": "Rota definida: {route}",
|
||||||
|
"contactFailedToSetRoute": "Falha ao definir a rota: {error}",
|
||||||
|
"rssi": "RSSI",
|
||||||
|
"snr": "SNR",
|
||||||
|
"ackTimeout": "Tempo limite ACK",
|
||||||
|
"opcode": "Opcode",
|
||||||
|
"payload": "Carga útil",
|
||||||
|
"hops": "Saltos",
|
||||||
|
"hashSize": "Tamanho do hash",
|
||||||
|
"pathBytes": "Bytes do caminho",
|
||||||
|
"selectedPath": "Caminho selecionado",
|
||||||
|
"estimatedTx": "Transmissão estimada",
|
||||||
|
"senderToReceipt": "Do remetente ao recibo",
|
||||||
|
"receivedCopies": "Cópias recebidas",
|
||||||
|
"retryCause": "Causa da nova tentativa",
|
||||||
|
"retryMode": "Modo de nova tentativa",
|
||||||
|
"retryResult": "Resultado da nova tentativa",
|
||||||
|
"lastRetry": "Última tentativa",
|
||||||
|
"rxPackets": "Pacotes RX",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Taxa",
|
||||||
|
"window": "Janela",
|
||||||
|
"posttxDelay": "Atraso pós-tx",
|
||||||
|
"bandpass": "Passa-banda",
|
||||||
|
"bandpassFilterVoice": "Filtro passa-banda de voz",
|
||||||
|
"frequency": "Frequência",
|
||||||
|
"australia": "Austrália",
|
||||||
|
"australiaNarrow": "Austrália (Estreito)",
|
||||||
|
"australiaQld": "Austrália: QLD",
|
||||||
|
"australiaSaWa": "Austrália: SA, WA",
|
||||||
|
"newZealand": "Nova Zelândia",
|
||||||
|
"newZealandNarrow": "Nova Zelândia (Estreito)",
|
||||||
|
"switzerland": "Suíça",
|
||||||
|
"portugal433": "Portugal 433",
|
||||||
|
"portugal868": "Portugal 868",
|
||||||
|
"czechRepublicNarrow": "República Checa (Estreito)",
|
||||||
|
"eu433mhzLongRange": "UE 433MHz (Longo Alcance)",
|
||||||
|
"euukDeprecated": "UE/RU (Obsoleto)",
|
||||||
|
"euukNarrow": "UE/RU (Estreito)",
|
||||||
|
"usacanadaRecommended": "EUA/Canadá (Recomendado)",
|
||||||
|
"vietnamDeprecated": "Vietname (Obsoleto)",
|
||||||
|
"vietnamNarrow": "Vietname (Estreito)",
|
||||||
|
"active": "Ativo",
|
||||||
|
"addContact": "Adicionar contacto",
|
||||||
|
"all": "Tudo",
|
||||||
|
"autoResolve": "Resolver automaticamente",
|
||||||
|
"clearAllLabel": "Limpar tudo",
|
||||||
|
"clearRelays": "Limpar relays",
|
||||||
|
"clearFilters": "Limpar filtros",
|
||||||
|
"clearRoute": "Limpar rota",
|
||||||
|
"clearMessages": "Limpar mensagens",
|
||||||
|
"clearScale": "Limpar escala",
|
||||||
|
"clearDiscoveries": "Limpar descobertas",
|
||||||
|
"clearOnlineTraceDatabase": "Limpar base de dados de rastreio",
|
||||||
|
"clearAllChannels": "Limpar todos os canais",
|
||||||
|
"clearAllContacts": "Limpar todos os contactos",
|
||||||
|
"clearChannels": "Limpar canais",
|
||||||
|
"clearContacts": "Limpar contactos",
|
||||||
|
"clearPathOnMaxRetry": "Limpar caminho no máx. de tentativas",
|
||||||
|
"create": "Criar",
|
||||||
|
"custom": "Personalizado",
|
||||||
|
"defaultValue": "Padrão",
|
||||||
|
"duplicate": "Duplicar",
|
||||||
|
"editName": "Editar nome",
|
||||||
|
"open": "Abrir",
|
||||||
|
"paste": "Colar",
|
||||||
|
"preview": "Pré-visualização",
|
||||||
|
"remove": "Remover",
|
||||||
|
"rename": "Renomear",
|
||||||
|
"resolveAll": "Resolver tudo",
|
||||||
|
"send": "Enviar",
|
||||||
|
"sendAnyway": "Enviar mesmo assim",
|
||||||
|
"share": "Partilhar",
|
||||||
|
"shareContact": "Partilhar contacto",
|
||||||
|
"trace": "Rastreio",
|
||||||
|
"use": "Usar",
|
||||||
|
"useSelectedFrequency": "Usar frequência selecionada",
|
||||||
|
"discovery": "Descoberta",
|
||||||
|
"discoverRepeaters": "Descobrir repetidores",
|
||||||
|
"discoverSensors": "Descobrir sensores",
|
||||||
|
"repeaterDiscoverySent": "Descoberta de repetidores enviada",
|
||||||
|
"sensorDiscoverySent": "Descoberta de sensores enviada",
|
||||||
|
"clearedPendingDiscoveries": "Descobertas pendentes limpas.",
|
||||||
|
"autoDiscovery": "Descoberta automática",
|
||||||
|
"enableAutomaticAdding": "Ativar adição automática",
|
||||||
|
"autoaddRepeaters": "Adicionar repetidores automaticamente",
|
||||||
|
"autoaddRoomServers": "Adicionar servidores de sala automaticamente",
|
||||||
|
"autoaddSensors": "Adicionar sensores automaticamente",
|
||||||
|
"autoaddUsers": "Adicionar utilizadores automaticamente",
|
||||||
|
"overwriteOldestWhenFull": "Sobrescrever mais antigos quando cheio",
|
||||||
|
"storage": "Armazenamento",
|
||||||
|
"dangerZone": "Zona de perigo",
|
||||||
|
"profiles": "Perfis",
|
||||||
|
"favourites": "Favoritos",
|
||||||
|
"sensors": "Sensores",
|
||||||
|
"others": "Outros",
|
||||||
|
"gpsModule": "Módulo GPS",
|
||||||
|
"liveTraffic": "Tráfego ao vivo",
|
||||||
|
"repeatersMap": "Mapa de repetidores",
|
||||||
|
"spectrumScan": "Varredura de espectro",
|
||||||
|
"blePacketLogs": "Registos de pacotes BLE",
|
||||||
|
"onlineTraceDatabase": "Base de dados de rastreio",
|
||||||
|
"routePathByteSize": "Tamanho do caminho em bytes",
|
||||||
|
"messageNotifications": "Notificações de mensagens",
|
||||||
|
"sarAlerts": "Alertas SAR",
|
||||||
|
"discoveryNotifications": "Notificações de descoberta",
|
||||||
|
"updateNotifications": "Notificações de atualização",
|
||||||
|
"muteWhileAppIsOpen": "Silenciar com a app aberta",
|
||||||
|
"disableContacts": "Desativar contactos",
|
||||||
|
"enableSensorsTab": "Ativar separador Sensores",
|
||||||
|
"enableProfiles": "Ativar perfis",
|
||||||
|
"autoRouteRotation": "Rotação automática de rota",
|
||||||
|
"nearestRepeaterFallback": "Repetidor mais próximo como reserva",
|
||||||
|
"deleteAllStoredMessageHistory": "Apagar todo o histórico de mensagens",
|
||||||
|
"messageFontSize": "Tamanho da fonte de mensagens",
|
||||||
|
"rotateMapWithHeading": "Rodar mapa com direção",
|
||||||
|
"showMapDebugInfo": "Mostrar info de depuração do mapa",
|
||||||
|
"openMapInFullscreen": "Abrir mapa em ecrã inteiro",
|
||||||
|
"showSarMarkersLabel": "Mostrar marcadores SAR",
|
||||||
|
"displaySarMarkersOnTheMainMap": "Mostrar marcadores SAR no mapa principal",
|
||||||
|
"showAllContactTrailsLabel": "Mostrar todos os rastos de contactos",
|
||||||
|
"hideRepeatersOnMap": "Ocultar repetidores no mapa",
|
||||||
|
"setMapScale": "Definir escala do mapa",
|
||||||
|
"customMapScaleSaved": "Escala de mapa personalizada guardada",
|
||||||
|
"voiceBitrate": "Taxa de bits de voz",
|
||||||
|
"voiceCompressor": "Compressor de voz",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Equilibra níveis de fala suaves e altos",
|
||||||
|
"voiceLimiter": "Limitador de voz",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Previne corte de picos antes da codificação",
|
||||||
|
"micAutoGain": "Ganho automático do microfone",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Permite ao gravador ajustar o nível de entrada",
|
||||||
|
"echoCancellation": "Cancelamento de eco",
|
||||||
|
"noiseSuppression": "Supressão de ruído",
|
||||||
|
"trimSilenceInVoiceMessages": "Cortar silêncio em mensagens de voz",
|
||||||
|
"compressor": "Compressor",
|
||||||
|
"limiter": "Limitador",
|
||||||
|
"autoGain": "Ganho automático",
|
||||||
|
"echoCancel": "Eco",
|
||||||
|
"noiseSuppress": "Ruído",
|
||||||
|
"silenceTrim": "Silêncio",
|
||||||
|
"maxImageSize": "Tamanho máximo de imagem",
|
||||||
|
"imageCompression": "Compressão de imagem",
|
||||||
|
"grayscale": "Escala de cinzentos",
|
||||||
|
"ultraMode": "Modo ultra",
|
||||||
|
"fastPrivateGpsUpdates": "Atualizações GPS privadas rápidas",
|
||||||
|
"movementThreshold": "Limiar de movimento",
|
||||||
|
"fastGpsMovementThreshold": "Limiar de movimento GPS rápido",
|
||||||
|
"fastGpsActiveuseInterval": "Intervalo de uso ativo GPS rápido",
|
||||||
|
"activeuseUpdateInterval": "Intervalo de atualização de uso ativo",
|
||||||
|
"repeatNearbyTraffic": "Repetir tráfego próximo",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Retransmitir através de repetidores na rede",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Apenas próximos, sem flood de repetidores",
|
||||||
|
"multihop": "Multi-salto",
|
||||||
|
"createProfile": "Criar perfil",
|
||||||
|
"renameProfile": "Renomear perfil",
|
||||||
|
"newProfile": "Novo perfil",
|
||||||
|
"manageProfiles": "Gerir perfis",
|
||||||
|
"enableProfilesToStartManagingThem": "Ative os perfis para começar a geri-los.",
|
||||||
|
"openMessage": "Abrir mensagem",
|
||||||
|
"jumpToTheRelatedSarMessage": "Ir para a mensagem SAR relacionada",
|
||||||
|
"removeSarMarker": "Remover marcador SAR",
|
||||||
|
"pleaseSelectADestinationToSendSarMarker": "Selecione um destino para enviar o marcador SAR",
|
||||||
|
"sarMarkerBroadcastToPublicChannel": "Marcador SAR transmitido no canal público",
|
||||||
|
"sarMarkerSentToRoom": "Marcador SAR enviado para a sala",
|
||||||
|
"loadFromGallery": "Carregar da galeria",
|
||||||
|
"replaceImage": "Substituir imagem",
|
||||||
|
"selectFromGallery": "Selecionar da galeria",
|
||||||
|
"team": "Equipa",
|
||||||
|
"found": "Encontrado",
|
||||||
|
"staging": "Área de reunião",
|
||||||
|
"object": "Objeto",
|
||||||
|
"quiet": "Silencioso",
|
||||||
|
"moderate": "Moderado",
|
||||||
|
"busy": "Ocupado",
|
||||||
|
"spectrumScanReturnedNoCandidateFrequencies": "A varredura de espectro não encontrou frequências candidatas",
|
||||||
|
"searchMessages": "Pesquisar mensagens",
|
||||||
|
"sendImageFromGallery": "Enviar imagem da galeria",
|
||||||
|
"takePhoto": "Tirar foto",
|
||||||
|
"dmOnly": "Apenas mensagem direta",
|
||||||
|
"allMessages": "Todas as mensagens",
|
||||||
|
"sendToPublicChannel": "Enviar para o canal público?",
|
||||||
|
"selectMarkerTypeAndDestination": "Selecione tipo de marcador e destino",
|
||||||
|
"noDestinationsAvailableLabel": "Nenhum destino disponível",
|
||||||
|
"image": "Imagem",
|
||||||
|
"format": "Formato",
|
||||||
|
"dimensions": "Dimensões",
|
||||||
|
"segments": "Segmentos",
|
||||||
|
"transfers": "Transferências",
|
||||||
|
"downloadedBy": "Transferido por",
|
||||||
|
"saveDiscoverySettings": "Guardar definições de descoberta",
|
||||||
|
"savePublicInfo": "Guardar informação pública",
|
||||||
|
"saveRadioSettings": "Guardar definições de rádio",
|
||||||
|
"savePath": "Guardar caminho",
|
||||||
|
"wipeDeviceData": "Apagar dados do dispositivo",
|
||||||
|
"wipeDevice": "Apagar dispositivo",
|
||||||
|
"destructiveDeviceActions": "Ações destrutivas do dispositivo.",
|
||||||
|
"chooseAPresetOrFinetuneCustomRadioSettings": "Escolha uma predefinição ou ajuste as definições de rádio.",
|
||||||
|
"chooseTheNameAndLocationThisDeviceShares": "Escolha o nome e a localização que este dispositivo partilha.",
|
||||||
|
"availableSpaceOnThisDevice": "Espaço disponível neste dispositivo.",
|
||||||
|
"used": "Usado",
|
||||||
|
"total": "Total",
|
||||||
|
"renameValue": "Renomear valor",
|
||||||
|
"customizeFields": "Personalizar campos",
|
||||||
|
"livePreview": "Pré-visualização ao vivo",
|
||||||
|
"refreshSchedule": "Agenda de atualização",
|
||||||
|
"noResponse": "Sem resposta",
|
||||||
|
"refreshing": "A atualizar",
|
||||||
|
"unavailable": "Indisponível",
|
||||||
|
"pickARelayOrNodeToWatchInSensors": "Escolha um relay ou nó para observar.",
|
||||||
|
"publicKeyLabel": "Chave pública",
|
||||||
|
"alreadyInContacts": "Já nos contactos",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Conecte-se a um dispositivo antes de adicionar contactos",
|
||||||
|
"fromContacts": "Dos contactos",
|
||||||
|
"onlineOnly": "Apenas online",
|
||||||
|
"inBoth": "Em ambos",
|
||||||
|
"source": "Fonte",
|
||||||
|
"manualRouteEdit": "Edição manual de rota",
|
||||||
|
"observedMeshRoute": "Rota mesh observada",
|
||||||
|
"allMessagesCleared": "Todas as mensagens limpas",
|
||||||
|
"onlineTraceDatabaseCleared": "Base de dados de rastreio limpa",
|
||||||
|
"packetLogsCleared": "Registos de pacotes limpos",
|
||||||
|
"hexDataCopiedToClipboard": "Dados hex copiados para a área de transferência",
|
||||||
|
"developerModeEnabled": "Modo de programador ativado",
|
||||||
|
"developerModeDisabled": "Modo de programador desativado",
|
||||||
|
"clipboardIsEmpty": "A área de transferência está vazia",
|
||||||
|
"contactImported": "Contacto importado",
|
||||||
|
"contactLinkCopiedToClipboard": "Link do contacto copiado para a área de transferência",
|
||||||
|
"failedToExportContact": "Falha ao exportar contacto",
|
||||||
|
"noLogsToExport": "Sem registos para exportar",
|
||||||
|
"exportAsCsv": "Exportar como CSV",
|
||||||
|
"exportAsText": "Exportar como texto",
|
||||||
|
"receivedRfc3339": "Recebido (RFC3339)",
|
||||||
|
"buildTime": "Hora de compilação",
|
||||||
|
"downloadUrlNotAvailable": "URL de download não disponível",
|
||||||
|
"cannotOpenDownloadUrl": "Não é possível abrir o URL de download",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "A verificação de atualizações só está disponível no Android",
|
||||||
|
"youAreRunningTheLatestVersion": "Está a usar a versão mais recente",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "Atualização disponível mas URL de download não encontrado",
|
||||||
|
"startTictactoe": "Iniciar Tic-Tac-Toe",
|
||||||
|
"tictactoeUnavailable": "Tic-Tac-Toe indisponível",
|
||||||
|
"tictactoeOpponentUnknown": "Tic-Tac-Toe: oponente desconhecido",
|
||||||
|
"tictactoeWaitingForStart": "Tic-Tac-Toe: a aguardar início",
|
||||||
|
"acceptsShareLinks": "Aceita links partilhados",
|
||||||
|
"supportsRawHex": "Suporta hex bruto",
|
||||||
|
"clipboardfriendly": "Compatível com a área de transferência",
|
||||||
|
"captured": "Capturado",
|
||||||
|
"size": "Tamanho",
|
||||||
|
"noCustomChannelsToClear": "Sem canais personalizados para limpar.",
|
||||||
|
"noDeviceContactsToClear": "Sem contactos do dispositivo para limpar."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,5 +511,304 @@
|
|||||||
"rawDump": "Необработанные данные",
|
"rawDump": "Необработанные данные",
|
||||||
"cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе",
|
"cannotRetryMissingRecipient": "Повтор невозможен: нет информации о получателе",
|
||||||
"voiceUnavailable": "Голос сейчас недоступен",
|
"voiceUnavailable": "Голос сейчас недоступен",
|
||||||
"requestingVoice": "Запрос голоса"
|
"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": "Нет контактов устройства для очистки."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -511,5 +511,304 @@
|
|||||||
"rawDump": "Surovi izpis",
|
"rawDump": "Surovi izpis",
|
||||||
"cannotRetryMissingRecipient": "Ponovnega pošiljanja ni mogoče: informacije o prejemniku manjkajo",
|
"cannotRetryMissingRecipient": "Ponovnega pošiljanja ni mogoče: informacije o prejemniku manjkajo",
|
||||||
"voiceUnavailable": "Glas trenutno ni na voljo",
|
"voiceUnavailable": "Glas trenutno ni na voljo",
|
||||||
"requestingVoice": "Zahteva za glasom"
|
"requestingVoice": "Zahteva za glasom",
|
||||||
|
"device": "naprava",
|
||||||
|
"change": "Spremeni",
|
||||||
|
"wizardOverviewDescription": "Ta aplikacija združuje sporočanje MeshCore, terenske SAR posodobitve, zemljevide in orodja za napravo na enem mestu.",
|
||||||
|
"wizardOverviewFeature1": "Pošiljajte neposredna sporočila, objave v sobah in sporočila na kanalih iz glavnega zavihka Sporočila.",
|
||||||
|
"wizardOverviewFeature2": "Delite SAR označevalce, risbe zemljevida, glasovne posnetke in slike prek mesh omrežja.",
|
||||||
|
"wizardOverviewFeature3": "Povežite se prek BLE ali TCP in nato upravljajte spremljevalni radio kar iz aplikacije.",
|
||||||
|
"wizardMessagingTitle": "Sporočanje in terenska poročila",
|
||||||
|
"wizardMessagingDescription": "Sporočila tukaj niso le navadno besedilo. Aplikacija že podpira več operativnih vrst vsebin in prenosnih tokov.",
|
||||||
|
"wizardMessagingFeature1": "Pošiljajte neposredna sporočila, objave v sobah in promet na kanalih iz enega urejevalnika.",
|
||||||
|
"wizardMessagingFeature2": "Ustvarjajte SAR posodobitve in večkrat uporabne SAR predloge za pogosta terenska poročila.",
|
||||||
|
"wizardMessagingFeature3": "Prenašajte glasovne seje in slike, vmesnik pa prikazuje napredek in oceno časa prenosa.",
|
||||||
|
"wizardConnectDeviceTitle": "Poveži napravo",
|
||||||
|
"wizardConnectDeviceDescription": "Povežite svoj MeshCore radio, izberite ime in uporabite radijski prednastavljeni profil, preden nadaljujete.",
|
||||||
|
"wizardSetupBadge": "Nastavitev",
|
||||||
|
"wizardOverviewBadge": "Pregled",
|
||||||
|
"wizardConnectedToDevice": "Povezano z: {deviceName}",
|
||||||
|
"wizardNoDeviceConnected": "Nobena naprava še ni povezana",
|
||||||
|
"wizardSkipForNow": "Za zdaj preskoči",
|
||||||
|
"wizardDeviceNameLabel": "Ime naprave",
|
||||||
|
"wizardDeviceNameHelp": "To ime se oglašuje drugim uporabnikom MeshCore.",
|
||||||
|
"wizardConfigRegionLabel": "Konfiguracijska regija",
|
||||||
|
"wizardConfigRegionHelp": "Uporabi celoten uradni seznam prednastavitev MeshCore. Privzeto je izbran EU/UK (Narrow).",
|
||||||
|
"wizardPresetNote1": "Prepričajte se, da izbrana prednastavitev ustreza lokalnim radijskim predpisom.",
|
||||||
|
"wizardPresetNote2": "Seznam ustreza uradnemu viru prednastavitev orodja MeshCore config.",
|
||||||
|
"wizardPresetNote3": "Za uvajanje ostane privzeto izbran EU/UK (Narrow).",
|
||||||
|
"wizardSaving": "Shranjujem...",
|
||||||
|
"wizardSaveAndContinue": "Shrani in nadaljuj",
|
||||||
|
"wizardEnterDeviceName": "Pred nadaljevanjem vnesite ime naprave.",
|
||||||
|
"wizardDeviceSetupSaved": "Shranjeno {deviceName} z nastavitvijo {presetName}.",
|
||||||
|
"wizardNetworkTitle": "Stiki, sobe in repetitorji",
|
||||||
|
"wizardNetworkDescription": "Zavihek Stiki organizira omrežje, ki ga odkrijete, in poti, ki se jih aplikacija nauči skozi čas.",
|
||||||
|
"wizardNetworkFeature1": "Na enem seznamu preglejte člane ekipe, repetitorje, sobe, kanale in čakajoče oglase.",
|
||||||
|
"wizardNetworkFeature2": "Uporabite smart ping, prijavo v sobe, naučene poti in ponastavitev poti, ko povezljivost postane neurejena.",
|
||||||
|
"wizardNetworkFeature3": "Ustvarjajte kanale in upravljajte omrežne cilje, ne da bi zapustili aplikacijo.",
|
||||||
|
"wizardMapOpsTitle": "Zemljevid, sledi in deljena geometrija",
|
||||||
|
"wizardMapOpsDescription": "Zemljevid v aplikaciji je neposredno povezan s sporočanjem, sledenjem in SAR prekrivanji, namesto da bi bil le ločen pregledovalnik.",
|
||||||
|
"wizardMapOpsFeature1": "Spremljajte svoj položaj, lokacije sotekmovalcev in gibanje sledi na zemljevidu.",
|
||||||
|
"wizardMapOpsFeature2": "Odprite risbe iz sporočil, jih predoglejte v vrstici in jih po potrebi odstranite z zemljevida.",
|
||||||
|
"wizardMapOpsFeature3": "Uporabite poglede repetitorjev in deljena prekrivanja za razumevanje dosega omrežja na terenu.",
|
||||||
|
"wizardToolsTitle": "Orodja onkraj sporočanja",
|
||||||
|
"wizardToolsDescription": "Tu je več kot le štirje glavni zavihki. Aplikacija vključuje tudi konfiguracijo, diagnostiko in neobvezne senzorske poteke.",
|
||||||
|
"wizardToolsFeature1": "Odprite nastavitve naprave za spremembo radijskih nastavitev, telemetrije, oddajne moči in podatkov spremljevalne naprave.",
|
||||||
|
"wizardToolsFeature2": "Omogočite zavihek Senzorji, kadar želite nadzorne plošče in hitra osveževanja spremljanih senzorjev.",
|
||||||
|
"wizardToolsFeature3": "Uporabite dnevnik paketov, pregled spektra in razvojno diagnostiko pri odpravljanju težav v mesh omrežju.",
|
||||||
|
"contactInSensors": "V senzorjih",
|
||||||
|
"contactAddToSensors": "Dodaj v senzorje",
|
||||||
|
"contactSetPath": "Nastavi pot",
|
||||||
|
"contactAddedToSensors": "{contactName} dodan v senzorje",
|
||||||
|
"contactFailedToClearRoute": "Brisanje poti ni uspelo: {error}",
|
||||||
|
"contactRouteCleared": "Pot izbrisana",
|
||||||
|
"contactRouteSet": "Pot nastavljena: {route}",
|
||||||
|
"contactFailedToSetRoute": "Nastavitev poti ni uspela: {error}",
|
||||||
|
"rssi": "RSSI",
|
||||||
|
"snr": "SNR",
|
||||||
|
"ackTimeout": "Časovna omejitev ACK",
|
||||||
|
"opcode": "Opkoda",
|
||||||
|
"payload": "Vsebina",
|
||||||
|
"hops": "Skoki",
|
||||||
|
"hashSize": "Velikost zgoščevalne vrednosti",
|
||||||
|
"pathBytes": "Bajti poti",
|
||||||
|
"selectedPath": "Izbrana pot",
|
||||||
|
"estimatedTx": "Ocenjeni čas oddaje",
|
||||||
|
"senderToReceipt": "Od pošiljatelja do prejema",
|
||||||
|
"receivedCopies": "Prejete kopije",
|
||||||
|
"retryCause": "Razlog ponovitve",
|
||||||
|
"retryMode": "Način ponovitve",
|
||||||
|
"retryResult": "Rezultat ponovitve",
|
||||||
|
"lastRetry": "Zadnja ponovitev",
|
||||||
|
"rxPackets": "RX paketi",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Hitrost",
|
||||||
|
"window": "Okno",
|
||||||
|
"posttxDelay": "Zakasnitev po oddaji",
|
||||||
|
"bandpass": "Pasovno sito",
|
||||||
|
"bandpassFilterVoice": "Pasovno sito za glas",
|
||||||
|
"frequency": "Frekvenca",
|
||||||
|
"australia": "Avstralija",
|
||||||
|
"australiaNarrow": "Avstralija (ozko)",
|
||||||
|
"australiaQld": "Avstralija: QLD",
|
||||||
|
"australiaSaWa": "Avstralija: SA, WA",
|
||||||
|
"newZealand": "Nova Zelandija",
|
||||||
|
"newZealandNarrow": "Nova Zelandija (ozko)",
|
||||||
|
"switzerland": "Švica",
|
||||||
|
"portugal433": "Portugalska 433",
|
||||||
|
"portugal868": "Portugalska 868",
|
||||||
|
"czechRepublicNarrow": "Češka (ozko)",
|
||||||
|
"eu433mhzLongRange": "EU 433MHz (dolg doseg)",
|
||||||
|
"euukDeprecated": "EU/UK (zastarelo)",
|
||||||
|
"euukNarrow": "EU/UK (ozko)",
|
||||||
|
"usacanadaRecommended": "ZDA/Kanada (priporočeno)",
|
||||||
|
"vietnamDeprecated": "Vietnam (zastarelo)",
|
||||||
|
"vietnamNarrow": "Vietnam (ozko)",
|
||||||
|
"active": "Aktivno",
|
||||||
|
"addContact": "Dodaj stik",
|
||||||
|
"all": "Vse",
|
||||||
|
"autoResolve": "Samodejno razreši",
|
||||||
|
"clearAllLabel": "Počisti vse",
|
||||||
|
"clearRelays": "Počisti posrednike",
|
||||||
|
"clearFilters": "Počisti filtre",
|
||||||
|
"clearRoute": "Počisti pot",
|
||||||
|
"clearMessages": "Počisti sporočila",
|
||||||
|
"clearScale": "Počisti merilo",
|
||||||
|
"clearDiscoveries": "Počisti odkritja",
|
||||||
|
"clearOnlineTraceDatabase": "Počisti bazo sledi",
|
||||||
|
"clearAllChannels": "Počisti vse kanale",
|
||||||
|
"clearAllContacts": "Počisti vse stike",
|
||||||
|
"clearChannels": "Počisti kanale",
|
||||||
|
"clearContacts": "Počisti stike",
|
||||||
|
"clearPathOnMaxRetry": "Počisti pot ob maks. ponovitvi",
|
||||||
|
"create": "Ustvari",
|
||||||
|
"custom": "Po meri",
|
||||||
|
"defaultValue": "Privzeto",
|
||||||
|
"duplicate": "Podvoji",
|
||||||
|
"editName": "Uredi ime",
|
||||||
|
"open": "Odpri",
|
||||||
|
"paste": "Prilepi",
|
||||||
|
"preview": "Predogled",
|
||||||
|
"remove": "Odstrani",
|
||||||
|
"rename": "Preimenuj",
|
||||||
|
"resolveAll": "Razreši vse",
|
||||||
|
"send": "Pošlji",
|
||||||
|
"sendAnyway": "Vseeno pošlji",
|
||||||
|
"share": "Deli",
|
||||||
|
"shareContact": "Deli stik",
|
||||||
|
"trace": "Sledenje",
|
||||||
|
"use": "Uporabi",
|
||||||
|
"useSelectedFrequency": "Uporabi izbrano frekvenco",
|
||||||
|
"discovery": "Odkrivanje",
|
||||||
|
"discoverRepeaters": "Odkrij posrednike",
|
||||||
|
"discoverSensors": "Odkrij senzorje",
|
||||||
|
"repeaterDiscoverySent": "Odkrivanje posrednikov poslano",
|
||||||
|
"sensorDiscoverySent": "Odkrivanje senzorjev poslano",
|
||||||
|
"clearedPendingDiscoveries": "Čakalna odkritja počiščena.",
|
||||||
|
"autoDiscovery": "Samodejno odkrivanje",
|
||||||
|
"enableAutomaticAdding": "Omogoči samodejno dodajanje",
|
||||||
|
"autoaddRepeaters": "Samodejno dodaj posrednike",
|
||||||
|
"autoaddRoomServers": "Samodejno dodaj strežnike sob",
|
||||||
|
"autoaddSensors": "Samodejno dodaj senzorje",
|
||||||
|
"autoaddUsers": "Samodejno dodaj uporabnike",
|
||||||
|
"overwriteOldestWhenFull": "Prepiši najstarejše ob polnem",
|
||||||
|
"storage": "Shramba",
|
||||||
|
"dangerZone": "Nevarno območje",
|
||||||
|
"profiles": "Profili",
|
||||||
|
"favourites": "Priljubljeni",
|
||||||
|
"sensors": "Senzorji",
|
||||||
|
"others": "Ostali",
|
||||||
|
"gpsModule": "GPS modul",
|
||||||
|
"liveTraffic": "Promet v živo",
|
||||||
|
"repeatersMap": "Zemljevid posrednikov",
|
||||||
|
"spectrumScan": "Spektralno skeniranje",
|
||||||
|
"blePacketLogs": "Dnevniki BLE paketov",
|
||||||
|
"onlineTraceDatabase": "Baza sledi",
|
||||||
|
"routePathByteSize": "Velikost poti v bajtih",
|
||||||
|
"messageNotifications": "Obvestila o sporočilih",
|
||||||
|
"sarAlerts": "SAR opozorila",
|
||||||
|
"discoveryNotifications": "Obvestila o odkritjih",
|
||||||
|
"updateNotifications": "Obvestila o posodobitvah",
|
||||||
|
"muteWhileAppIsOpen": "Utišaj ko je aplikacija odprta",
|
||||||
|
"disableContacts": "Onemogoči stike",
|
||||||
|
"enableSensorsTab": "Omogoči zavihek Senzorji",
|
||||||
|
"enableProfiles": "Omogoči profile",
|
||||||
|
"autoRouteRotation": "Samodejno kroženje poti",
|
||||||
|
"nearestRepeaterFallback": "Najbližji posrednik kot rezerva",
|
||||||
|
"deleteAllStoredMessageHistory": "Izbriši vso shranjeno zgodovino sporočil",
|
||||||
|
"messageFontSize": "Velikost pisave sporočil",
|
||||||
|
"rotateMapWithHeading": "Vrti zemljevid s smerjo",
|
||||||
|
"showMapDebugInfo": "Prikaži razhroščevalne info zemljevida",
|
||||||
|
"openMapInFullscreen": "Odpri zemljevid na cel zaslon",
|
||||||
|
"showSarMarkersLabel": "Prikaži SAR oznake",
|
||||||
|
"displaySarMarkersOnTheMainMap": "Prikaži SAR oznake na glavnem zemljevidu",
|
||||||
|
"showAllContactTrailsLabel": "Prikaži vse sledi stikov",
|
||||||
|
"hideRepeatersOnMap": "Skrij posrednike na zemljevidu",
|
||||||
|
"setMapScale": "Nastavi merilo zemljevida",
|
||||||
|
"customMapScaleSaved": "Merilo zemljevida shranjeno",
|
||||||
|
"voiceBitrate": "Bitna hitrost glasu",
|
||||||
|
"voiceCompressor": "Kompresor glasu",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Izravnava tihe in glasne govorne ravni",
|
||||||
|
"voiceLimiter": "Omejevalnik glasu",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Preprečuje rezanje vrhov pred kodiranjem",
|
||||||
|
"micAutoGain": "Samodejna ojačitev mikrofona",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Omogoči snemalniku prilagoditev vhodne ravni",
|
||||||
|
"echoCancellation": "Odstranjevanje odmeva",
|
||||||
|
"noiseSuppression": "Dušenje šuma",
|
||||||
|
"trimSilenceInVoiceMessages": "Obreži tišino v glasovnih sporočilih",
|
||||||
|
"compressor": "Kompresor",
|
||||||
|
"limiter": "Omejevalnik",
|
||||||
|
"autoGain": "Samodejna ojačitev",
|
||||||
|
"echoCancel": "Odmev",
|
||||||
|
"noiseSuppress": "Šum",
|
||||||
|
"silenceTrim": "Tišina",
|
||||||
|
"maxImageSize": "Največja velikost slike",
|
||||||
|
"imageCompression": "Stiskanje slik",
|
||||||
|
"grayscale": "Sivine",
|
||||||
|
"ultraMode": "Ultra način",
|
||||||
|
"fastPrivateGpsUpdates": "Hitri zasebni GPS podatki",
|
||||||
|
"movementThreshold": "Prag gibanja",
|
||||||
|
"fastGpsMovementThreshold": "Prag gibanja za hitri GPS",
|
||||||
|
"fastGpsActiveuseInterval": "Interval uporabe hitrega GPS-a",
|
||||||
|
"activeuseUpdateInterval": "Interval posodobitve pri aktivni uporabi",
|
||||||
|
"repeatNearbyTraffic": "Ponavljaj bližnji promet",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Posreduj skozi posrednike po mreži",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Samo bližnji, brez poplavljanja posrednikov",
|
||||||
|
"multihop": "Večskočno",
|
||||||
|
"createProfile": "Ustvari profil",
|
||||||
|
"renameProfile": "Preimenuj profil",
|
||||||
|
"newProfile": "Nov profil",
|
||||||
|
"manageProfiles": "Upravljaj profile",
|
||||||
|
"enableProfilesToStartManagingThem": "Omogočite profile, da jih začnete upravljati.",
|
||||||
|
"openMessage": "Odpri sporočilo",
|
||||||
|
"jumpToTheRelatedSarMessage": "Skoči na povezano SAR sporočilo",
|
||||||
|
"removeSarMarker": "Odstrani SAR oznako",
|
||||||
|
"pleaseSelectADestinationToSendSarMarker": "Izberite cilj za pošiljanje SAR oznake",
|
||||||
|
"sarMarkerBroadcastToPublicChannel": "SAR oznaka poslana na javni kanal",
|
||||||
|
"sarMarkerSentToRoom": "SAR oznaka poslana v sobo",
|
||||||
|
"loadFromGallery": "Naloži iz galerije",
|
||||||
|
"replaceImage": "Zamenjaj sliko",
|
||||||
|
"selectFromGallery": "Izberi iz galerije",
|
||||||
|
"team": "Ekipa",
|
||||||
|
"found": "Najdeno",
|
||||||
|
"staging": "Zbirno mesto",
|
||||||
|
"object": "Predmet",
|
||||||
|
"quiet": "Tiho",
|
||||||
|
"moderate": "Zmerno",
|
||||||
|
"busy": "Zasedeno",
|
||||||
|
"spectrumScanReturnedNoCandidateFrequencies": "Spektralno skeniranje ni našlo primernih frekvenc",
|
||||||
|
"searchMessages": "Iskanje sporočil",
|
||||||
|
"sendImageFromGallery": "Pošlji sliko iz galerije",
|
||||||
|
"takePhoto": "Posnemi fotografijo",
|
||||||
|
"dmOnly": "Samo neposredno",
|
||||||
|
"allMessages": "Vsa sporočila",
|
||||||
|
"sendToPublicChannel": "Pošlji na javni kanal?",
|
||||||
|
"selectMarkerTypeAndDestination": "Izberite vrsto oznake in cilj",
|
||||||
|
"noDestinationsAvailableLabel": "Ni razpoložljivih ciljev",
|
||||||
|
"image": "Slika",
|
||||||
|
"format": "Format",
|
||||||
|
"dimensions": "Dimenzije",
|
||||||
|
"segments": "Segmenti",
|
||||||
|
"transfers": "Prenosi",
|
||||||
|
"downloadedBy": "Preneseno od",
|
||||||
|
"saveDiscoverySettings": "Shrani nastavitve odkrivanja",
|
||||||
|
"savePublicInfo": "Shrani javne podatke",
|
||||||
|
"saveRadioSettings": "Shrani radijske nastavitve",
|
||||||
|
"savePath": "Shrani pot",
|
||||||
|
"wipeDeviceData": "Izbriši podatke naprave",
|
||||||
|
"wipeDevice": "Izbriši napravo",
|
||||||
|
"destructiveDeviceActions": "Destruktivna dejanja na napravi.",
|
||||||
|
"chooseAPresetOrFinetuneCustomRadioSettings": "Izberite prednastavitev ali natančno prilagodite radijske nastavitve.",
|
||||||
|
"chooseTheNameAndLocationThisDeviceShares": "Izberite ime in lokacijo, ki ju naprava deli.",
|
||||||
|
"availableSpaceOnThisDevice": "Razpoložljiv prostor na tej napravi.",
|
||||||
|
"used": "Uporabljeno",
|
||||||
|
"total": "Skupaj",
|
||||||
|
"renameValue": "Preimenuj vrednost",
|
||||||
|
"customizeFields": "Prilagodi polja",
|
||||||
|
"livePreview": "Predogled v živo",
|
||||||
|
"refreshSchedule": "Interval osvežitve",
|
||||||
|
"noResponse": "Ni odgovora",
|
||||||
|
"refreshing": "Osvežujem",
|
||||||
|
"unavailable": "Nedostopno",
|
||||||
|
"pickARelayOrNodeToWatchInSensors": "Izberite posrednika ali vozlišče za opazovanje.",
|
||||||
|
"publicKeyLabel": "Javni ključ",
|
||||||
|
"alreadyInContacts": "Že med stiki",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Povežite se z napravo pred dodajanjem stikov",
|
||||||
|
"fromContacts": "Iz stikov",
|
||||||
|
"onlineOnly": "Samo povezani",
|
||||||
|
"inBoth": "V obeh",
|
||||||
|
"source": "Vir",
|
||||||
|
"manualRouteEdit": "Ročno urejanje poti",
|
||||||
|
"observedMeshRoute": "Opazovana mrežna pot",
|
||||||
|
"allMessagesCleared": "Vsa sporočila počiščena",
|
||||||
|
"onlineTraceDatabaseCleared": "Baza sledi počiščena",
|
||||||
|
"packetLogsCleared": "Dnevniki paketov počiščeni",
|
||||||
|
"hexDataCopiedToClipboard": "Hex podatki kopirani v odložišče",
|
||||||
|
"developerModeEnabled": "Razvojni način omogočen",
|
||||||
|
"developerModeDisabled": "Razvojni način onemogočen",
|
||||||
|
"clipboardIsEmpty": "Odložišče je prazno",
|
||||||
|
"contactImported": "Stik uvožen",
|
||||||
|
"contactLinkCopiedToClipboard": "Povezava na stik kopirana v odložišče",
|
||||||
|
"failedToExportContact": "Izvoz stika ni uspel",
|
||||||
|
"noLogsToExport": "Ni dnevnikov za izvoz",
|
||||||
|
"exportAsCsv": "Izvozi kot CSV",
|
||||||
|
"exportAsText": "Izvozi kot besedilo",
|
||||||
|
"receivedRfc3339": "Prejeto (RFC3339)",
|
||||||
|
"buildTime": "Čas gradnje",
|
||||||
|
"downloadUrlNotAvailable": "URL za prenos ni na voljo",
|
||||||
|
"cannotOpenDownloadUrl": "URL za prenos ni mogoče odpreti",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "Preverjanje posodobitev je na voljo le na Androidu",
|
||||||
|
"youAreRunningTheLatestVersion": "Uporabljate najnovejšo različico",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "Posodobitev na voljo, a URL za prenos ni najden",
|
||||||
|
"startTictactoe": "Začni Tic-Tac-Toe",
|
||||||
|
"tictactoeUnavailable": "Tic-Tac-Toe ni na voljo",
|
||||||
|
"tictactoeOpponentUnknown": "Tic-Tac-Toe: nasprotnik neznan",
|
||||||
|
"tictactoeWaitingForStart": "Tic-Tac-Toe: čakanje na začetek",
|
||||||
|
"acceptsShareLinks": "Sprejema deljene povezave",
|
||||||
|
"supportsRawHex": "Podpira surove hex",
|
||||||
|
"clipboardfriendly": "Primerno za odložišče",
|
||||||
|
"captured": "Zajeto",
|
||||||
|
"size": "Velikost",
|
||||||
|
"noCustomChannelsToClear": "Ni prilagojenih kanalov za brisanje.",
|
||||||
|
"noDeviceContactsToClear": "Ni stikov naprave za brisanje."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2276,5 +2276,304 @@
|
|||||||
"voiceUnavailable": "Ses şu anda kullanılamıyor",
|
"voiceUnavailable": "Ses şu anda kullanılamıyor",
|
||||||
"@voiceUnavailable": {},
|
"@voiceUnavailable": {},
|
||||||
"requestingVoice": "Ses isteniyor",
|
"requestingVoice": "Ses isteniyor",
|
||||||
"@requestingVoice": {}
|
"@requestingVoice": {},
|
||||||
|
"device": "cihaz",
|
||||||
|
"change": "Değiştir",
|
||||||
|
"wizardOverviewDescription": "Bu uygulama MeshCore mesajlaşmasını, SAR saha güncellemelerini, haritalamayı ve cihaz araçlarını tek bir yerde birleştirir.",
|
||||||
|
"wizardOverviewFeature1": "Ana Mesajlar sekmesinden doğrudan mesajlar, oda gönderileri ve kanal mesajları gönderin.",
|
||||||
|
"wizardOverviewFeature2": "Mesh üzerinden SAR işaretleri, harita çizimleri, ses klipleri ve görseller paylaşın.",
|
||||||
|
"wizardOverviewFeature3": "BLE veya TCP ile bağlanın, ardından yardımcı radyoyu uygulamanın içinden yönetin.",
|
||||||
|
"wizardMessagingTitle": "Mesajlaşma ve saha raporları",
|
||||||
|
"wizardMessagingDescription": "Buradaki mesajlar düz metinden fazlasıdır. Uygulama zaten çeşitli operasyonel yükleri ve aktarım akışlarını destekliyor.",
|
||||||
|
"wizardMessagingFeature1": "Doğrudan mesajları, oda gönderilerini ve kanal trafiğini tek bir düzenleyiciden gönderin.",
|
||||||
|
"wizardMessagingFeature2": "Yaygın saha raporları için SAR güncellemeleri ve yeniden kullanılabilir SAR şablonları oluşturun.",
|
||||||
|
"wizardMessagingFeature3": "Arayüzde ilerleme ve yayın süresi tahminleriyle ses oturumları ve görseller aktarın.",
|
||||||
|
"wizardConnectDeviceTitle": "Cihazı bağla",
|
||||||
|
"wizardConnectDeviceDescription": "MeshCore radyonuzu bağlayın, bir ad seçin ve devam etmeden önce bir radyo ön ayarı uygulayın.",
|
||||||
|
"wizardSetupBadge": "Kurulum",
|
||||||
|
"wizardOverviewBadge": "Genel bakış",
|
||||||
|
"wizardConnectedToDevice": "{deviceName} cihazına bağlandı",
|
||||||
|
"wizardNoDeviceConnected": "Henüz bağlı cihaz yok",
|
||||||
|
"wizardSkipForNow": "Şimdilik atla",
|
||||||
|
"wizardDeviceNameLabel": "Cihaz adı",
|
||||||
|
"wizardDeviceNameHelp": "Bu ad diğer MeshCore kullanıcılarına duyurulur.",
|
||||||
|
"wizardConfigRegionLabel": "Yapılandırma bölgesi",
|
||||||
|
"wizardConfigRegionHelp": "MeshCore'un resmi ön ayar listesinin tamamını kullanır. Varsayılan EU/UK (Narrow) olur.",
|
||||||
|
"wizardPresetNote1": "Seçilen ön ayarın yerel radyo düzenlemelerinize uygun olduğundan emin olun.",
|
||||||
|
"wizardPresetNote2": "Liste, MeshCore yapılandırma aracının resmi ön ayar akışıyla eşleşir.",
|
||||||
|
"wizardPresetNote3": "Onboarding sırasında varsayılan olarak EU/UK (Narrow) seçili kalır.",
|
||||||
|
"wizardSaving": "Kaydediliyor...",
|
||||||
|
"wizardSaveAndContinue": "Kaydet ve devam et",
|
||||||
|
"wizardEnterDeviceName": "Devam etmeden önce bir cihaz adı girin.",
|
||||||
|
"wizardDeviceSetupSaved": "{deviceName}, {presetName} ile kaydedildi.",
|
||||||
|
"wizardNetworkTitle": "Kişiler, odalar ve tekrarlayıcılar",
|
||||||
|
"wizardNetworkDescription": "Kişiler sekmesi keşfettiğiniz ağı ve zamanla öğrendiğiniz rotaları düzenler.",
|
||||||
|
"wizardNetworkFeature1": "Ekip üyelerini, tekrarlayıcıları, odaları, kanalları ve bekleyen duyuruları tek listede inceleyin.",
|
||||||
|
"wizardNetworkFeature2": "Bağlantı karmaşıklaştığında smart ping, oda girişi, öğrenilen yollar ve rota sıfırlama araçlarını kullanın.",
|
||||||
|
"wizardNetworkFeature3": "Uygulamadan çıkmadan kanallar oluşturun ve ağ hedeflerini yönetin.",
|
||||||
|
"wizardMapOpsTitle": "Harita, izler ve paylaşılan geometri",
|
||||||
|
"wizardMapOpsDescription": "Uygulama haritası ayrı bir görüntüleyici olmak yerine mesajlaşma, takip ve SAR katmanlarıyla doğrudan bağlantılıdır.",
|
||||||
|
"wizardMapOpsFeature1": "Kendi konumunuzu, ekip arkadaşlarının konumlarını ve hareket izlerini harita üzerinde takip edin.",
|
||||||
|
"wizardMapOpsFeature2": "Mesajlardaki çizimleri açın, satır içinde önizleyin ve gerektiğinde haritadan kaldırın.",
|
||||||
|
"wizardMapOpsFeature3": "Sahadaki ağ kapsamasını anlamak için tekrarlayıcı harita görünümleri ve paylaşılan katmanları kullanın.",
|
||||||
|
"wizardToolsTitle": "Mesajlaşma dışındaki araçlar",
|
||||||
|
"wizardToolsDescription": "Burada dört ana sekmeden fazlası var. Uygulama ayrıca yapılandırma, tanılama ve isteğe bağlı sensör akışları içerir.",
|
||||||
|
"wizardToolsFeature1": "Radyo ayarlarını, telemetriyi, TX gücünü ve yardımcı cihaz ayrıntılarını değiştirmek için cihaz yapılandırmasını açın.",
|
||||||
|
"wizardToolsFeature2": "İzlenen sensör panoları ve hızlı yenileme eylemleri istediğinizde Sensörler sekmesini etkinleştirin.",
|
||||||
|
"wizardToolsFeature3": "Mesh sorunlarını giderirken paket günlükleri, spektrum taraması ve geliştirici tanılamalarını kullanın.",
|
||||||
|
"contactInSensors": "Sensörlerde",
|
||||||
|
"contactAddToSensors": "Sensörlere ekle",
|
||||||
|
"contactSetPath": "Yol ayarla",
|
||||||
|
"contactAddedToSensors": "{contactName} Sensörlere eklendi",
|
||||||
|
"contactFailedToClearRoute": "Rota temizlenemedi: {error}",
|
||||||
|
"contactRouteCleared": "Rota temizlendi",
|
||||||
|
"contactRouteSet": "Rota ayarlandı: {route}",
|
||||||
|
"contactFailedToSetRoute": "Rota ayarlanamadı: {error}",
|
||||||
|
"rssi": "RSSI",
|
||||||
|
"snr": "SNR",
|
||||||
|
"ackTimeout": "ACK zaman aşımı",
|
||||||
|
"opcode": "Opcode",
|
||||||
|
"payload": "Yük",
|
||||||
|
"hops": "Atlama",
|
||||||
|
"hashSize": "Hash boyutu",
|
||||||
|
"pathBytes": "Yol baytları",
|
||||||
|
"selectedPath": "Seçilen yol",
|
||||||
|
"estimatedTx": "Tahmini gönderim",
|
||||||
|
"senderToReceipt": "Gönderenden alındıya",
|
||||||
|
"receivedCopies": "Alınan kopyalar",
|
||||||
|
"retryCause": "Yeniden deneme nedeni",
|
||||||
|
"retryMode": "Yeniden deneme modu",
|
||||||
|
"retryResult": "Yeniden deneme sonucu",
|
||||||
|
"lastRetry": "Son deneme",
|
||||||
|
"rxPackets": "RX paketleri",
|
||||||
|
"mesh": "Mesh",
|
||||||
|
"rate": "Hız",
|
||||||
|
"window": "Pencere",
|
||||||
|
"posttxDelay": "Gönderim sonrası gecikme",
|
||||||
|
"bandpass": "Bant geçiren",
|
||||||
|
"bandpassFilterVoice": "Bant geçiren filtre ses",
|
||||||
|
"frequency": "Frekans",
|
||||||
|
"australia": "Avustralya",
|
||||||
|
"australiaNarrow": "Avustralya (Dar)",
|
||||||
|
"australiaQld": "Avustralya: QLD",
|
||||||
|
"australiaSaWa": "Avustralya: SA, WA",
|
||||||
|
"newZealand": "Yeni Zelanda",
|
||||||
|
"newZealandNarrow": "Yeni Zelanda (Dar)",
|
||||||
|
"switzerland": "İsviçre",
|
||||||
|
"portugal433": "Portekiz 433",
|
||||||
|
"portugal868": "Portekiz 868",
|
||||||
|
"czechRepublicNarrow": "Çek Cumhuriyeti (Dar)",
|
||||||
|
"eu433mhzLongRange": "AB 433MHz (Uzun Menzil)",
|
||||||
|
"euukDeprecated": "AB/BK (Kullanımdan kalktı)",
|
||||||
|
"euukNarrow": "AB/BK (Dar)",
|
||||||
|
"usacanadaRecommended": "ABD/Kanada (Önerilen)",
|
||||||
|
"vietnamDeprecated": "Vietnam (Kullanımdan kalktı)",
|
||||||
|
"vietnamNarrow": "Vietnam (Dar)",
|
||||||
|
"active": "Aktif",
|
||||||
|
"addContact": "Kişi ekle",
|
||||||
|
"all": "Tümü",
|
||||||
|
"autoResolve": "Otomatik çöz",
|
||||||
|
"clearAllLabel": "Tümünü temizle",
|
||||||
|
"clearRelays": "Röleleri temizle",
|
||||||
|
"clearFilters": "Filtreleri temizle",
|
||||||
|
"clearRoute": "Rotayı temizle",
|
||||||
|
"clearMessages": "Mesajları temizle",
|
||||||
|
"clearScale": "Ölçeği temizle",
|
||||||
|
"clearDiscoveries": "Keşifleri temizle",
|
||||||
|
"clearOnlineTraceDatabase": "Çevrimiçi iz veritabanını temizle",
|
||||||
|
"clearAllChannels": "Tüm kanalları temizle",
|
||||||
|
"clearAllContacts": "Tüm kişileri temizle",
|
||||||
|
"clearChannels": "Kanalları temizle",
|
||||||
|
"clearContacts": "Kişileri temizle",
|
||||||
|
"clearPathOnMaxRetry": "Maks denemede yolu temizle",
|
||||||
|
"create": "Oluştur",
|
||||||
|
"custom": "Özel",
|
||||||
|
"defaultValue": "Varsayılan",
|
||||||
|
"duplicate": "Çoğalt",
|
||||||
|
"editName": "Adı düzenle",
|
||||||
|
"open": "Aç",
|
||||||
|
"paste": "Yapıştır",
|
||||||
|
"preview": "Önizleme",
|
||||||
|
"remove": "Kaldır",
|
||||||
|
"rename": "Yeniden adlandır",
|
||||||
|
"resolveAll": "Tümünü çöz",
|
||||||
|
"send": "Gönder",
|
||||||
|
"sendAnyway": "Yine de gönder",
|
||||||
|
"share": "Paylaş",
|
||||||
|
"shareContact": "Kişiyi paylaş",
|
||||||
|
"trace": "İz",
|
||||||
|
"use": "Kullan",
|
||||||
|
"useSelectedFrequency": "Seçilen frekansı kullan",
|
||||||
|
"discovery": "Keşif",
|
||||||
|
"discoverRepeaters": "Tekrarlayıcıları keşfet",
|
||||||
|
"discoverSensors": "Sensörleri keşfet",
|
||||||
|
"repeaterDiscoverySent": "Tekrarlayıcı keşfi gönderildi",
|
||||||
|
"sensorDiscoverySent": "Sensör keşfi gönderildi",
|
||||||
|
"clearedPendingDiscoveries": "Bekleyen keşifler temizlendi.",
|
||||||
|
"autoDiscovery": "Otomatik keşif",
|
||||||
|
"enableAutomaticAdding": "Otomatik eklemeyi etkinleştir",
|
||||||
|
"autoaddRepeaters": "Tekrarlayıcıları otomatik ekle",
|
||||||
|
"autoaddRoomServers": "Oda sunucularını otomatik ekle",
|
||||||
|
"autoaddSensors": "Sensörleri otomatik ekle",
|
||||||
|
"autoaddUsers": "Kullanıcıları otomatik ekle",
|
||||||
|
"overwriteOldestWhenFull": "Dolduğunda en eskiyi üzerine yaz",
|
||||||
|
"storage": "Depolama",
|
||||||
|
"dangerZone": "Tehlikeli bölge",
|
||||||
|
"profiles": "Profiller",
|
||||||
|
"favourites": "Favoriler",
|
||||||
|
"sensors": "Sensörler",
|
||||||
|
"others": "Diğerleri",
|
||||||
|
"gpsModule": "GPS Modülü",
|
||||||
|
"liveTraffic": "Canlı trafik",
|
||||||
|
"repeatersMap": "Tekrarlayıcılar haritası",
|
||||||
|
"spectrumScan": "Spektrum taraması",
|
||||||
|
"blePacketLogs": "BLE paket günlükleri",
|
||||||
|
"onlineTraceDatabase": "Çevrimiçi iz veritabanı",
|
||||||
|
"routePathByteSize": "Yol bayt boyutu",
|
||||||
|
"messageNotifications": "Mesaj bildirimleri",
|
||||||
|
"sarAlerts": "SAR uyarıları",
|
||||||
|
"discoveryNotifications": "Keşif bildirimleri",
|
||||||
|
"updateNotifications": "Güncelleme bildirimleri",
|
||||||
|
"muteWhileAppIsOpen": "Uygulama açıkken sessize al",
|
||||||
|
"disableContacts": "Kişileri devre dışı bırak",
|
||||||
|
"enableSensorsTab": "Sensörler sekmesini etkinleştir",
|
||||||
|
"enableProfiles": "Profilleri etkinleştir",
|
||||||
|
"autoRouteRotation": "Otomatik rota rotasyonu",
|
||||||
|
"nearestRepeaterFallback": "En yakın tekrarlayıcıya geri dönüş",
|
||||||
|
"deleteAllStoredMessageHistory": "Tüm mesaj geçmişini sil",
|
||||||
|
"messageFontSize": "Mesaj yazı tipi boyutu",
|
||||||
|
"rotateMapWithHeading": "Haritayı yön ile döndür",
|
||||||
|
"showMapDebugInfo": "Harita hata ayıklama bilgisini göster",
|
||||||
|
"openMapInFullscreen": "Haritayı tam ekranda aç",
|
||||||
|
"showSarMarkersLabel": "SAR işaretçilerini göster",
|
||||||
|
"displaySarMarkersOnTheMainMap": "SAR işaretçilerini ana haritada göster",
|
||||||
|
"showAllContactTrailsLabel": "Tüm kişi izlerini göster",
|
||||||
|
"hideRepeatersOnMap": "Haritada tekrarlayıcıları gizle",
|
||||||
|
"setMapScale": "Harita ölçeğini ayarla",
|
||||||
|
"customMapScaleSaved": "Özel harita ölçeği kaydedildi",
|
||||||
|
"voiceBitrate": "Ses bit hızı",
|
||||||
|
"voiceCompressor": "Ses sıkıştırıcı",
|
||||||
|
"balancesQuietAndLoudSpeechLevels": "Sessiz ve yüksek konuşma seviyelerini dengeler",
|
||||||
|
"voiceLimiter": "Ses sınırlayıcı",
|
||||||
|
"preventsClippingPeaksBeforeEncoding": "Kodlama öncesi tepe kırpmasını önler",
|
||||||
|
"micAutoGain": "Mikrofon otomatik kazancı",
|
||||||
|
"letsTheRecorderAdjustInputLevel": "Kaydedicinin giriş seviyesini ayarlamasına izin verir",
|
||||||
|
"echoCancellation": "Yankı iptali",
|
||||||
|
"noiseSuppression": "Gürültü bastırma",
|
||||||
|
"trimSilenceInVoiceMessages": "Sesli mesajlarda sessizliği kırp",
|
||||||
|
"compressor": "Sıkıştırıcı",
|
||||||
|
"limiter": "Sınırlayıcı",
|
||||||
|
"autoGain": "Otomatik kazanç",
|
||||||
|
"echoCancel": "Yankı",
|
||||||
|
"noiseSuppress": "Gürültü",
|
||||||
|
"silenceTrim": "Sessizlik",
|
||||||
|
"maxImageSize": "Maksimum resim boyutu",
|
||||||
|
"imageCompression": "Resim sıkıştırma",
|
||||||
|
"grayscale": "Gri tonlama",
|
||||||
|
"ultraMode": "Ultra mod",
|
||||||
|
"fastPrivateGpsUpdates": "Hızlı özel GPS güncellemeleri",
|
||||||
|
"movementThreshold": "Hareket eşiği",
|
||||||
|
"fastGpsMovementThreshold": "Hızlı GPS hareket eşiği",
|
||||||
|
"fastGpsActiveuseInterval": "Hızlı GPS aktif kullanım aralığı",
|
||||||
|
"activeuseUpdateInterval": "Aktif kullanım güncelleme aralığı",
|
||||||
|
"repeatNearbyTraffic": "Yakın trafiği tekrarla",
|
||||||
|
"relayThroughRepeatersAcrossTheMesh": "Ağ üzerindeki tekrarlayıcılar aracılığıyla ilet",
|
||||||
|
"nearbyOnlyWithoutRepeaterFlooding": "Yalnızca yakın, tekrarlayıcı taşkını olmadan",
|
||||||
|
"multihop": "Çok atlamalı",
|
||||||
|
"createProfile": "Profil oluştur",
|
||||||
|
"renameProfile": "Profili yeniden adlandır",
|
||||||
|
"newProfile": "Yeni profil",
|
||||||
|
"manageProfiles": "Profilleri yönet",
|
||||||
|
"enableProfilesToStartManagingThem": "Profilleri yönetmeye başlamak için etkinleştirin.",
|
||||||
|
"openMessage": "Mesajı aç",
|
||||||
|
"jumpToTheRelatedSarMessage": "İlgili SAR mesajına git",
|
||||||
|
"removeSarMarker": "SAR işaretçisini kaldır",
|
||||||
|
"pleaseSelectADestinationToSendSarMarker": "SAR işaretçisi göndermek için bir hedef seçin",
|
||||||
|
"sarMarkerBroadcastToPublicChannel": "SAR işaretçisi genel kanala yayınlandı",
|
||||||
|
"sarMarkerSentToRoom": "SAR işaretçisi odaya gönderildi",
|
||||||
|
"loadFromGallery": "Galeriden yükle",
|
||||||
|
"replaceImage": "Resmi değiştir",
|
||||||
|
"selectFromGallery": "Galeriden seç",
|
||||||
|
"team": "Takım",
|
||||||
|
"found": "Bulundu",
|
||||||
|
"staging": "Toplanma alanı",
|
||||||
|
"object": "Nesne",
|
||||||
|
"quiet": "Sessiz",
|
||||||
|
"moderate": "Orta",
|
||||||
|
"busy": "Meşgul",
|
||||||
|
"spectrumScanReturnedNoCandidateFrequencies": "Spektrum taraması aday frekans bulamadı",
|
||||||
|
"searchMessages": "Mesajlarda ara",
|
||||||
|
"sendImageFromGallery": "Galeriden resim gönder",
|
||||||
|
"takePhoto": "Fotoğraf çek",
|
||||||
|
"dmOnly": "Yalnızca DM",
|
||||||
|
"allMessages": "Tüm mesajlar",
|
||||||
|
"sendToPublicChannel": "Genel kanala gönderilsin mi?",
|
||||||
|
"selectMarkerTypeAndDestination": "İşaretçi türünü ve hedefi seçin",
|
||||||
|
"noDestinationsAvailableLabel": "Kullanılabilir hedef yok",
|
||||||
|
"image": "Resim",
|
||||||
|
"format": "Biçim",
|
||||||
|
"dimensions": "Boyutlar",
|
||||||
|
"segments": "Segmentler",
|
||||||
|
"transfers": "Transferler",
|
||||||
|
"downloadedBy": "İndiren",
|
||||||
|
"saveDiscoverySettings": "Keşif ayarlarını kaydet",
|
||||||
|
"savePublicInfo": "Genel bilgileri kaydet",
|
||||||
|
"saveRadioSettings": "Radyo ayarlarını kaydet",
|
||||||
|
"savePath": "Yolu kaydet",
|
||||||
|
"wipeDeviceData": "Cihaz verilerini sil",
|
||||||
|
"wipeDevice": "Cihazı sil",
|
||||||
|
"destructiveDeviceActions": "Yıkıcı cihaz işlemleri.",
|
||||||
|
"chooseAPresetOrFinetuneCustomRadioSettings": "Bir ön ayar seçin veya radyo ayarlarını ince ayarlayın.",
|
||||||
|
"chooseTheNameAndLocationThisDeviceShares": "Bu cihazın paylaştığı ad ve konumu seçin.",
|
||||||
|
"availableSpaceOnThisDevice": "Bu cihazda kullanılabilir alan.",
|
||||||
|
"used": "Kullanılan",
|
||||||
|
"total": "Toplam",
|
||||||
|
"renameValue": "Değeri yeniden adlandır",
|
||||||
|
"customizeFields": "Alanları özelleştir",
|
||||||
|
"livePreview": "Canlı önizleme",
|
||||||
|
"refreshSchedule": "Yenileme programı",
|
||||||
|
"noResponse": "Yanıt yok",
|
||||||
|
"refreshing": "Yenileniyor",
|
||||||
|
"unavailable": "Kullanılamaz",
|
||||||
|
"pickARelayOrNodeToWatchInSensors": "İzlemek için bir röle veya düğüm seçin.",
|
||||||
|
"publicKeyLabel": "Genel anahtar",
|
||||||
|
"alreadyInContacts": "Zaten kişilerde",
|
||||||
|
"connectToADeviceBeforeAddingContacts": "Kişi eklemeden önce bir cihaza bağlanın",
|
||||||
|
"fromContacts": "Kişilerden",
|
||||||
|
"onlineOnly": "Yalnızca çevrimiçi",
|
||||||
|
"inBoth": "Her ikisinde",
|
||||||
|
"source": "Kaynak",
|
||||||
|
"manualRouteEdit": "Manuel rota düzenle",
|
||||||
|
"observedMeshRoute": "Gözlemlenen mesh rotası",
|
||||||
|
"allMessagesCleared": "Tüm mesajlar temizlendi",
|
||||||
|
"onlineTraceDatabaseCleared": "Çevrimiçi iz veritabanı temizlendi",
|
||||||
|
"packetLogsCleared": "Paket günlükleri temizlendi",
|
||||||
|
"hexDataCopiedToClipboard": "Hex verisi panoya kopyalandı",
|
||||||
|
"developerModeEnabled": "Geliştirici modu etkinleştirildi",
|
||||||
|
"developerModeDisabled": "Geliştirici modu devre dışı bırakıldı",
|
||||||
|
"clipboardIsEmpty": "Pano boş",
|
||||||
|
"contactImported": "Kişi içe aktarıldı",
|
||||||
|
"contactLinkCopiedToClipboard": "Kişi bağlantısı panoya kopyalandı",
|
||||||
|
"failedToExportContact": "Kişi dışa aktarılamadı",
|
||||||
|
"noLogsToExport": "Dışa aktarılacak günlük yok",
|
||||||
|
"exportAsCsv": "CSV olarak dışa aktar",
|
||||||
|
"exportAsText": "Metin olarak dışa aktar",
|
||||||
|
"receivedRfc3339": "Alındı (RFC3339)",
|
||||||
|
"buildTime": "Derleme zamanı",
|
||||||
|
"downloadUrlNotAvailable": "İndirme URL'si mevcut değil",
|
||||||
|
"cannotOpenDownloadUrl": "İndirme URL'si açılamıyor",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "Güncelleme kontrolü yalnızca Android'de kullanılabilir",
|
||||||
|
"youAreRunningTheLatestVersion": "En son sürümü kullanıyorsunuz",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "Güncelleme mevcut ancak indirme URL'si bulunamadı",
|
||||||
|
"startTictactoe": "Tic-Tac-Toe başlat",
|
||||||
|
"tictactoeUnavailable": "Tic-Tac-Toe kullanılamıyor",
|
||||||
|
"tictactoeOpponentUnknown": "Tic-Tac-Toe: rakip bilinmiyor",
|
||||||
|
"tictactoeWaitingForStart": "Tic-Tac-Toe: başlangıç bekleniyor",
|
||||||
|
"acceptsShareLinks": "Paylaşım bağlantılarını kabul eder",
|
||||||
|
"supportsRawHex": "Ham hex destekler",
|
||||||
|
"clipboardfriendly": "Pano dostu",
|
||||||
|
"captured": "Yakalandı",
|
||||||
|
"size": "Boyut",
|
||||||
|
"noCustomChannelsToClear": "Temizlenecek özel kanal yok.",
|
||||||
|
"noDeviceContactsToClear": "Temizlenecek cihaz kişisi yok."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2276,5 +2276,304 @@
|
|||||||
"voiceUnavailable": "Голос зараз недоступний",
|
"voiceUnavailable": "Голос зараз недоступний",
|
||||||
"@voiceUnavailable": {},
|
"@voiceUnavailable": {},
|
||||||
"requestingVoice": "Запит голосу",
|
"requestingVoice": "Запит голосу",
|
||||||
"@requestingVoice": {}
|
"@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": "Немає контактів пристрою для очищення."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2228,78 +2228,352 @@
|
|||||||
"description": "没有可发送的聊天联系人时的消息"
|
"description": "没有可发送的聊天联系人时的消息"
|
||||||
},
|
},
|
||||||
"reply": "回复",
|
"reply": "回复",
|
||||||
"@reply": {
|
"@reply": {},
|
||||||
},
|
|
||||||
"technicalDetails": "技术细节",
|
"technicalDetails": "技术细节",
|
||||||
"@technicalDetails": {
|
"@technicalDetails": {},
|
||||||
},
|
|
||||||
"messageTechnicalDetails": "消息技术细节",
|
"messageTechnicalDetails": "消息技术细节",
|
||||||
"@messageTechnicalDetails": {
|
"@messageTechnicalDetails": {},
|
||||||
},
|
|
||||||
"linkQuality": "链路质量",
|
"linkQuality": "链路质量",
|
||||||
"@linkQuality": {
|
"@linkQuality": {},
|
||||||
},
|
|
||||||
"delivery": "投递",
|
"delivery": "投递",
|
||||||
"@delivery": {
|
"@delivery": {},
|
||||||
},
|
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"@status": {
|
"@status": {},
|
||||||
},
|
|
||||||
"expectedAckTag": "预期的 ACK 标签",
|
"expectedAckTag": "预期的 ACK 标签",
|
||||||
"@expectedAckTag": {
|
"@expectedAckTag": {},
|
||||||
},
|
|
||||||
"roundTrip": "往返时间",
|
"roundTrip": "往返时间",
|
||||||
"@roundTrip": {
|
"@roundTrip": {},
|
||||||
},
|
|
||||||
"retryAttempt": "重试尝试",
|
"retryAttempt": "重试尝试",
|
||||||
"@retryAttempt": {
|
"@retryAttempt": {},
|
||||||
},
|
|
||||||
"floodFallback": "泛洪回退",
|
"floodFallback": "泛洪回退",
|
||||||
"@floodFallback": {
|
"@floodFallback": {},
|
||||||
},
|
|
||||||
"identity": "身份",
|
"identity": "身份",
|
||||||
"@identity": {
|
"@identity": {},
|
||||||
},
|
|
||||||
"messageId": "消息 ID",
|
"messageId": "消息 ID",
|
||||||
"@messageId": {
|
"@messageId": {},
|
||||||
},
|
|
||||||
"sender": "发送者",
|
"sender": "发送者",
|
||||||
"@sender": {
|
"@sender": {},
|
||||||
},
|
|
||||||
"senderKey": "发送者密钥",
|
"senderKey": "发送者密钥",
|
||||||
"@senderKey": {
|
"@senderKey": {},
|
||||||
},
|
|
||||||
"recipient": "接收者",
|
"recipient": "接收者",
|
||||||
"@recipient": {
|
"@recipient": {},
|
||||||
},
|
|
||||||
"recipientKey": "接收者密钥",
|
"recipientKey": "接收者密钥",
|
||||||
"@recipientKey": {
|
"@recipientKey": {},
|
||||||
},
|
|
||||||
"voice": "语音",
|
"voice": "语音",
|
||||||
"@voice": {
|
"@voice": {},
|
||||||
},
|
|
||||||
"voiceId": "语音 ID",
|
"voiceId": "语音 ID",
|
||||||
"@voiceId": {
|
"@voiceId": {},
|
||||||
},
|
|
||||||
"envelope": "信封",
|
"envelope": "信封",
|
||||||
"@envelope": {
|
"@envelope": {},
|
||||||
},
|
|
||||||
"sessionProgress": "会话进度",
|
"sessionProgress": "会话进度",
|
||||||
"@sessionProgress": {
|
"@sessionProgress": {},
|
||||||
},
|
|
||||||
"complete": "完成",
|
"complete": "完成",
|
||||||
"@complete": {
|
"@complete": {},
|
||||||
},
|
|
||||||
"rawDump": "原始数据",
|
"rawDump": "原始数据",
|
||||||
"@rawDump": {
|
"@rawDump": {},
|
||||||
},
|
|
||||||
"cannotRetryMissingRecipient": "无法重试:缺少接收者信息",
|
"cannotRetryMissingRecipient": "无法重试:缺少接收者信息",
|
||||||
"@cannotRetryMissingRecipient": {
|
"@cannotRetryMissingRecipient": {},
|
||||||
},
|
|
||||||
"voiceUnavailable": "当前语音不可用",
|
"voiceUnavailable": "当前语音不可用",
|
||||||
"@voiceUnavailable": {
|
"@voiceUnavailable": {},
|
||||||
},
|
|
||||||
"requestingVoice": "正在请求语音",
|
"requestingVoice": "正在请求语音",
|
||||||
"@requestingVoice": {
|
"@requestingVoice": {},
|
||||||
}
|
"device": "设备",
|
||||||
|
"change": "更改",
|
||||||
|
"wizardOverviewDescription": "此应用将 MeshCore 消息、SAR 现场更新、地图功能和设备工具整合到同一处。",
|
||||||
|
"wizardOverviewFeature1": "在主消息标签页中发送私聊消息、房间消息和频道消息。",
|
||||||
|
"wizardOverviewFeature2": "通过 mesh 网络共享 SAR 标记、地图绘图、语音片段和图像。",
|
||||||
|
"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 配置工具的官方预设源保持一致。",
|
||||||
|
"wizardPresetNote3": "在引导设置中默认保持选中 EU/UK (Narrow)。",
|
||||||
|
"wizardSaving": "正在保存...",
|
||||||
|
"wizardSaveAndContinue": "保存并继续",
|
||||||
|
"wizardEnterDeviceName": "继续前请输入设备名称。",
|
||||||
|
"wizardDeviceSetupSaved": "已保存 {deviceName},配置为 {presetName}。",
|
||||||
|
"wizardNetworkTitle": "联系人、房间和中继器",
|
||||||
|
"wizardNetworkDescription": "联系人标签页会整理你发现的网络以及随着时间学习到的路由。",
|
||||||
|
"wizardNetworkFeature1": "在一个列表中查看团队成员、中继器、房间、频道和待处理广播。",
|
||||||
|
"wizardNetworkFeature2": "当连接变得混乱时,使用智能 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": "欧盟 433MHz(远距离)",
|
||||||
|
"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": "仅附近,无中继器泛洪",
|
||||||
|
"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": "观察到的网格路由",
|
||||||
|
"allMessagesCleared": "所有消息已清除",
|
||||||
|
"onlineTraceDatabaseCleared": "在线追踪数据库已清除",
|
||||||
|
"packetLogsCleared": "数据包日志已清除",
|
||||||
|
"hexDataCopiedToClipboard": "十六进制数据已复制到剪贴板",
|
||||||
|
"developerModeEnabled": "开发者模式已启用",
|
||||||
|
"developerModeDisabled": "开发者模式已禁用",
|
||||||
|
"clipboardIsEmpty": "剪贴板为空",
|
||||||
|
"contactImported": "联系人已导入",
|
||||||
|
"contactLinkCopiedToClipboard": "联系人链接已复制到剪贴板",
|
||||||
|
"failedToExportContact": "导出联系人失败",
|
||||||
|
"noLogsToExport": "没有日志可导出",
|
||||||
|
"exportAsCsv": "导出为 CSV",
|
||||||
|
"exportAsText": "导出为文本",
|
||||||
|
"receivedRfc3339": "收到 (RFC3339)",
|
||||||
|
"buildTime": "构建时间",
|
||||||
|
"downloadUrlNotAvailable": "下载链接不可用",
|
||||||
|
"cannotOpenDownloadUrl": "无法打开下载链接",
|
||||||
|
"updateCheckIsOnlyAvailableOnAndroid": "更新检查仅在 Android 上可用",
|
||||||
|
"youAreRunningTheLatestVersion": "您正在使用最新版本",
|
||||||
|
"updateAvailableButDownloadUrlNotFound": "有更新可用但未找到下载链接",
|
||||||
|
"startTictactoe": "开始井字棋",
|
||||||
|
"tictactoeUnavailable": "井字棋不可用",
|
||||||
|
"tictactoeOpponentUnknown": "井字棋:对手未知",
|
||||||
|
"tictactoeWaitingForStart": "井字棋:等待开始",
|
||||||
|
"acceptsShareLinks": "接受分享链接",
|
||||||
|
"supportsRawHex": "支持原始十六进制",
|
||||||
|
"clipboardfriendly": "剪贴板友好",
|
||||||
|
"captured": "已捕获",
|
||||||
|
"size": "大小",
|
||||||
|
"noCustomChannelsToClear": "没有自定义频道可清除。",
|
||||||
|
"noDeviceContactsToClear": "没有设备联系人可清除。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,209 @@
|
|||||||
{}
|
{
|
||||||
|
"de": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"el": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"es": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"fr": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"hr": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"it": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"pl": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"pt": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"ru": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"sl": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"tr": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"uk": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
],
|
||||||
|
|
||||||
|
"zh": [
|
||||||
|
"postConnectDiscoveryTitle",
|
||||||
|
"postConnectDiscoveryDescription",
|
||||||
|
"setRegionScope",
|
||||||
|
"regionScope",
|
||||||
|
"regionScopeNone",
|
||||||
|
"clearRegionScope",
|
||||||
|
"regionScopeWarning",
|
||||||
|
"discoverRegions",
|
||||||
|
"discoveringRegions",
|
||||||
|
"enterRegionName",
|
||||||
|
"noRegionsFound",
|
||||||
|
"regionScopeSet",
|
||||||
|
"regionScopeCleared"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -19,14 +23,21 @@ import 'services/voice_codec_service.dart';
|
|||||||
import 'services/voice_player_service.dart';
|
import 'services/voice_player_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 +49,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 +64,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 +83,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 +92,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 +115,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 +208,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;
|
||||||
}
|
}
|
||||||
@@ -235,6 +285,13 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
),
|
),
|
||||||
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
ChangeNotifierProvider(create: (_) => SensorsProvider()),
|
||||||
|
ChangeNotifierProvider(
|
||||||
|
create: (_) {
|
||||||
|
final manager = ProfileManager();
|
||||||
|
manager.initialize();
|
||||||
|
return manager;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// Voice provider (packet reassembly + playback)
|
// Voice provider (packet reassembly + playback)
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
@@ -287,6 +344,39 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
|
|||||||
imageProvider: context.read<ip.ImageProvider>(),
|
imageProvider: context.read<ip.ImageProvider>(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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(),
|
||||||
);
|
);
|
||||||
@@ -297,6 +387,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}',
|
||||||
),
|
),
|
||||||
@@ -324,7 +415,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,9 @@ 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);
|
||||||
|
|
||||||
/// 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 +158,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,
|
||||||
|
|||||||
523
lib/models/config_profile.dart
Normal file
523
lib/models/config_profile.dart
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
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? autoRouteRotationEnabled;
|
||||||
|
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.autoRouteRotationEnabled,
|
||||||
|
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 &&
|
||||||
|
autoRouteRotationEnabled == 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,
|
||||||
|
'autoRouteRotationEnabled': autoRouteRotationEnabled,
|
||||||
|
'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(),
|
||||||
|
autoRouteRotationEnabled: json['autoRouteRotationEnabled'] as bool?,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,23 @@ class ContactRouteCodec {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
static ParsedContactRoute? fromContact(Contact contact) {
|
||||||
if (!contact.routeHasPath || contact.routeHopCount == 0) {
|
if (!contact.routeHasPath || contact.routeHopCount == 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -240,6 +257,5 @@ extension ContactLocalization on Contact {
|
|||||||
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
return '$routeHopCount hop${routeHopCount == 1 ? '' : 's'} via $routeHashSize-byte hashes';
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get routeSupportsLegacyRawTransport =>
|
bool get routeSupportsLegacyRawTransport => routeHasPath;
|
||||||
routeHasPath && routeSignedPathLen >= 0;
|
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
||||||
@@ -90,6 +96,7 @@ class DeviceInfo {
|
|||||||
|
|
||||||
// Repeat mode (firmware v9+)
|
// Repeat mode (firmware v9+)
|
||||||
final bool? clientRepeat;
|
final bool? clientRepeat;
|
||||||
|
final int? pathHashMode;
|
||||||
final bool? supportsSpectrumScan;
|
final bool? supportsSpectrumScan;
|
||||||
final int? spectrumScanMinKhz;
|
final int? spectrumScanMinKhz;
|
||||||
final int? spectrumScanMaxKhz;
|
final int? spectrumScanMaxKhz;
|
||||||
@@ -113,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,
|
||||||
@@ -129,6 +142,7 @@ class DeviceInfo {
|
|||||||
this.manufacturerModel,
|
this.manufacturerModel,
|
||||||
this.semanticVersion,
|
this.semanticVersion,
|
||||||
this.clientRepeat,
|
this.clientRepeat,
|
||||||
|
this.pathHashMode,
|
||||||
this.supportsSpectrumScan,
|
this.supportsSpectrumScan,
|
||||||
this.spectrumScanMinKhz,
|
this.spectrumScanMinKhz,
|
||||||
this.spectrumScanMaxKhz,
|
this.spectrumScanMaxKhz,
|
||||||
@@ -239,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,
|
||||||
@@ -255,6 +275,7 @@ class DeviceInfo {
|
|||||||
String? manufacturerModel,
|
String? manufacturerModel,
|
||||||
String? semanticVersion,
|
String? semanticVersion,
|
||||||
bool? clientRepeat,
|
bool? clientRepeat,
|
||||||
|
int? pathHashMode,
|
||||||
bool? supportsSpectrumScan,
|
bool? supportsSpectrumScan,
|
||||||
int? spectrumScanMinKhz,
|
int? spectrumScanMinKhz,
|
||||||
int? spectrumScanMaxKhz,
|
int? spectrumScanMaxKhz,
|
||||||
@@ -278,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,
|
||||||
@@ -294,6 +322,7 @@ 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,
|
||||||
|
pathHashMode: pathHashMode ?? this.pathHashMode,
|
||||||
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
|
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
|
||||||
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
|
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
|
||||||
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
|
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,
|
||||||
|
|||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class MessageReceptionDetails {
|
|||||||
final int? senderToReceiptMs;
|
final int? senderToReceiptMs;
|
||||||
final int? estimatedTransmitMs;
|
final int? estimatedTransmitMs;
|
||||||
final int? postTransmitDelayMs;
|
final int? postTransmitDelayMs;
|
||||||
|
final int receivedCopies;
|
||||||
|
|
||||||
const MessageReceptionDetails({
|
const MessageReceptionDetails({
|
||||||
required this.capturedAt,
|
required this.capturedAt,
|
||||||
@@ -40,6 +41,7 @@ class MessageReceptionDetails {
|
|||||||
this.senderToReceiptMs,
|
this.senderToReceiptMs,
|
||||||
this.estimatedTransmitMs,
|
this.estimatedTransmitMs,
|
||||||
this.postTransmitDelayMs,
|
this.postTransmitDelayMs,
|
||||||
|
this.receivedCopies = 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
String? get pathBytesHex => pathBytes == null || pathBytes!.isEmpty
|
||||||
@@ -56,9 +58,60 @@ class MessageReceptionDetails {
|
|||||||
'senderToReceiptMs': senderToReceiptMs,
|
'senderToReceiptMs': senderToReceiptMs,
|
||||||
'estimatedTransmitMs': estimatedTransmitMs,
|
'estimatedTransmitMs': estimatedTransmitMs,
|
||||||
'postTransmitDelayMs': postTransmitDelayMs,
|
'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) {
|
static MessageReceptionDetails? fromJson(Map<String, dynamic> json) {
|
||||||
final capturedAtMillis = json['capturedAtMillis'];
|
final capturedAtMillis = json['capturedAtMillis'];
|
||||||
if (capturedAtMillis is! int) {
|
if (capturedAtMillis is! int) {
|
||||||
@@ -81,6 +134,7 @@ class MessageReceptionDetails {
|
|||||||
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
senderToReceiptMs: json['senderToReceiptMs'] as int?,
|
||||||
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
estimatedTransmitMs: json['estimatedTransmitMs'] as int?,
|
||||||
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
postTransmitDelayMs: json['postTransmitDelayMs'] as int?,
|
||||||
|
receivedCopies: json['receivedCopies'] as int? ?? 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
|
enum PathRecordSource { learned, observed }
|
||||||
|
|
||||||
class PathRecord {
|
class PathRecord {
|
||||||
final List<int> pathBytes;
|
final List<int> pathBytes;
|
||||||
final int hopCount;
|
final int hopCount;
|
||||||
final int hashSize;
|
final int hashSize;
|
||||||
|
final PathRecordSource source;
|
||||||
final int successCount;
|
final int successCount;
|
||||||
final int failureCount;
|
final int failureCount;
|
||||||
final int lastRoundTripTimeMs;
|
final int lastRoundTripTimeMs;
|
||||||
final DateTime lastUsedAt;
|
final DateTime lastUsedAt;
|
||||||
|
final DateTime? lastSucceededAt;
|
||||||
|
final double? senderLatitude;
|
||||||
|
final double? senderLongitude;
|
||||||
|
final double? recipientLatitude;
|
||||||
|
final double? recipientLongitude;
|
||||||
|
|
||||||
const PathRecord({
|
const PathRecord({
|
||||||
required this.pathBytes,
|
required this.pathBytes,
|
||||||
required this.hopCount,
|
required this.hopCount,
|
||||||
required this.hashSize,
|
required this.hashSize,
|
||||||
|
required this.source,
|
||||||
required this.successCount,
|
required this.successCount,
|
||||||
required this.failureCount,
|
required this.failureCount,
|
||||||
required this.lastRoundTripTimeMs,
|
required this.lastRoundTripTimeMs,
|
||||||
required this.lastUsedAt,
|
required this.lastUsedAt,
|
||||||
|
required this.lastSucceededAt,
|
||||||
|
required this.senderLatitude,
|
||||||
|
required this.senderLongitude,
|
||||||
|
required this.recipientLatitude,
|
||||||
|
required this.recipientLongitude,
|
||||||
});
|
});
|
||||||
|
|
||||||
String get signature =>
|
String get signature =>
|
||||||
@@ -27,19 +41,31 @@ class PathRecord {
|
|||||||
List<int>? pathBytes,
|
List<int>? pathBytes,
|
||||||
int? hopCount,
|
int? hopCount,
|
||||||
int? hashSize,
|
int? hashSize,
|
||||||
|
PathRecordSource? source,
|
||||||
int? successCount,
|
int? successCount,
|
||||||
int? failureCount,
|
int? failureCount,
|
||||||
int? lastRoundTripTimeMs,
|
int? lastRoundTripTimeMs,
|
||||||
DateTime? lastUsedAt,
|
DateTime? lastUsedAt,
|
||||||
|
DateTime? lastSucceededAt,
|
||||||
|
double? senderLatitude,
|
||||||
|
double? senderLongitude,
|
||||||
|
double? recipientLatitude,
|
||||||
|
double? recipientLongitude,
|
||||||
}) {
|
}) {
|
||||||
return PathRecord(
|
return PathRecord(
|
||||||
pathBytes: pathBytes ?? this.pathBytes,
|
pathBytes: pathBytes ?? this.pathBytes,
|
||||||
hopCount: hopCount ?? this.hopCount,
|
hopCount: hopCount ?? this.hopCount,
|
||||||
hashSize: hashSize ?? this.hashSize,
|
hashSize: hashSize ?? this.hashSize,
|
||||||
|
source: source ?? this.source,
|
||||||
successCount: successCount ?? this.successCount,
|
successCount: successCount ?? this.successCount,
|
||||||
failureCount: failureCount ?? this.failureCount,
|
failureCount: failureCount ?? this.failureCount,
|
||||||
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
|
lastRoundTripTimeMs: lastRoundTripTimeMs ?? this.lastRoundTripTimeMs,
|
||||||
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
|
lastUsedAt: lastUsedAt ?? this.lastUsedAt,
|
||||||
|
lastSucceededAt: lastSucceededAt ?? this.lastSucceededAt,
|
||||||
|
senderLatitude: senderLatitude ?? this.senderLatitude,
|
||||||
|
senderLongitude: senderLongitude ?? this.senderLongitude,
|
||||||
|
recipientLatitude: recipientLatitude ?? this.recipientLatitude,
|
||||||
|
recipientLongitude: recipientLongitude ?? this.recipientLongitude,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,10 +74,16 @@ class PathRecord {
|
|||||||
'path_bytes': pathBytes,
|
'path_bytes': pathBytes,
|
||||||
'hop_count': hopCount,
|
'hop_count': hopCount,
|
||||||
'hash_size': hashSize,
|
'hash_size': hashSize,
|
||||||
|
'source': source.name,
|
||||||
'success_count': successCount,
|
'success_count': successCount,
|
||||||
'failure_count': failureCount,
|
'failure_count': failureCount,
|
||||||
'last_round_trip_time_ms': lastRoundTripTimeMs,
|
'last_round_trip_time_ms': lastRoundTripTimeMs,
|
||||||
'last_used_at': lastUsedAt.toIso8601String(),
|
'last_used_at': lastUsedAt.toIso8601String(),
|
||||||
|
'last_succeeded_at': lastSucceededAt?.toIso8601String(),
|
||||||
|
'sender_latitude': senderLatitude,
|
||||||
|
'sender_longitude': senderLongitude,
|
||||||
|
'recipient_latitude': recipientLatitude,
|
||||||
|
'recipient_longitude': recipientLongitude,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,12 +94,23 @@ class PathRecord {
|
|||||||
.toList(),
|
.toList(),
|
||||||
hopCount: json['hop_count'] as int? ?? 0,
|
hopCount: json['hop_count'] as int? ?? 0,
|
||||||
hashSize: json['hash_size'] as int? ?? 1,
|
hashSize: json['hash_size'] as int? ?? 1,
|
||||||
|
source: PathRecordSource.values.firstWhere(
|
||||||
|
(value) => value.name == (json['source'] as String? ?? 'learned'),
|
||||||
|
orElse: () => PathRecordSource.learned,
|
||||||
|
),
|
||||||
successCount: json['success_count'] as int? ?? 0,
|
successCount: json['success_count'] as int? ?? 0,
|
||||||
failureCount: json['failure_count'] as int? ?? 0,
|
failureCount: json['failure_count'] as int? ?? 0,
|
||||||
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
|
lastRoundTripTimeMs: json['last_round_trip_time_ms'] as int? ?? 0,
|
||||||
lastUsedAt:
|
lastUsedAt:
|
||||||
DateTime.tryParse(json['last_used_at'] as String? ?? '') ??
|
DateTime.tryParse(json['last_used_at'] as String? ?? '') ??
|
||||||
DateTime.fromMillisecondsSinceEpoch(0),
|
DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
lastSucceededAt: DateTime.tryParse(
|
||||||
|
json['last_succeeded_at'] as String? ?? '',
|
||||||
|
),
|
||||||
|
senderLatitude: (json['sender_latitude'] as num?)?.toDouble(),
|
||||||
|
senderLongitude: (json['sender_longitude'] as num?)?.toDouble(),
|
||||||
|
recipientLatitude: (json['recipient_latitude'] as num?)?.toDouble(),
|
||||||
|
recipientLongitude: (json['recipient_longitude'] as num?)?.toDouble(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +206,10 @@ class ContactPathHistory {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<PathRecord> get observedPaths => directPaths
|
||||||
|
.where((record) => record.source == PathRecordSource.observed)
|
||||||
|
.toList();
|
||||||
|
|
||||||
factory ContactPathHistory.fromJson(
|
factory ContactPathHistory.fromJson(
|
||||||
String contactPublicKeyHex,
|
String contactPublicKeyHex,
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
|
|||||||
@@ -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];
|
||||||
@@ -54,12 +55,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 +97,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
/// Message delivery tracking helper
|
/// Message delivery tracking helper
|
||||||
@@ -30,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
|
||||||
@@ -112,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
|
||||||
@@ -122,6 +127,7 @@ class MessageDeliveryTracker {
|
|||||||
if (ackTag != null) {
|
if (ackTag != null) {
|
||||||
_ackTagToMessageId.remove(ackTag);
|
_ackTagToMessageId.remove(ackTag);
|
||||||
_ackTagTimestamps.remove(ackTag);
|
_ackTagTimestamps.remove(ackTag);
|
||||||
|
_notifySlotFreed();
|
||||||
}
|
}
|
||||||
_pendingMessageIds.remove(messageId);
|
_pendingMessageIds.remove(messageId);
|
||||||
final emptyKeys = <String>[];
|
final emptyKeys = <String>[];
|
||||||
@@ -166,6 +172,7 @@ class MessageDeliveryTracker {
|
|||||||
_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
|
||||||
@@ -179,6 +186,27 @@ 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;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import '../../models/message.dart';
|
import '../../models/message.dart';
|
||||||
import '../../models/contact.dart';
|
import '../../models/contact.dart';
|
||||||
@@ -19,10 +20,19 @@ class MessageRetryManager {
|
|||||||
final Map<String, DateTime> _lastRetryTimes = {};
|
final Map<String, DateTime> _lastRetryTimes = {};
|
||||||
final Map<String, int> _pathFailureStreaks = {};
|
final Map<String, int> _pathFailureStreaks = {};
|
||||||
|
|
||||||
static const int maxRetryAttempts = 4;
|
/// Max retry attempts when the contact has a known path.
|
||||||
|
/// Sequence: 2 direct attempts, flood, then last successful route.
|
||||||
|
static const int maxRetryAttemptsWithPath = 3;
|
||||||
|
|
||||||
|
/// No retries for flood-only contacts (no known path).
|
||||||
|
/// Value 0 means: don't retry at all, go straight to fallback/fail.
|
||||||
|
static const int maxRetryAttemptsFloodOnly = 0;
|
||||||
|
|
||||||
|
@Deprecated('Use maxRetryAttemptsForContact instead')
|
||||||
|
static const int maxRetryAttempts = maxRetryAttemptsWithPath;
|
||||||
|
|
||||||
// Retry backoff values in milliseconds.
|
// Retry backoff values in milliseconds.
|
||||||
static const List<int> _retryDelays = [1000, 2000, 4000, 8000];
|
static const List<int> _retryDelays = [1000, 2000, 4000, 8000, 8000];
|
||||||
static const int _defaultLoRaSf = 10;
|
static const int _defaultLoRaSf = 10;
|
||||||
static const int _defaultLoRaCr = 5;
|
static const int _defaultLoRaCr = 5;
|
||||||
static const int _defaultLoRaBwHz = 250000;
|
static const int _defaultLoRaBwHz = 250000;
|
||||||
@@ -38,32 +48,57 @@ class MessageRetryManager {
|
|||||||
return _retryDelays[attempt];
|
return _retryDelays[attempt];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate a conservative delivery-ACK timeout when firmware doesn't
|
static final math.Random _rng = math.Random();
|
||||||
/// provide one or returns an invalid value.
|
|
||||||
|
/// Calculate a delivery-ACK timeout with random jitter.
|
||||||
|
///
|
||||||
|
/// Matches the official MeshCore app: `suggestedTimeout + random(1-8s)`.
|
||||||
|
/// The jitter prevents collision when multiple messages are in flight.
|
||||||
int calculateAckTimeoutMs({
|
int calculateAckTimeoutMs({
|
||||||
required String text,
|
required String text,
|
||||||
required Contact? contact,
|
required Contact? contact,
|
||||||
int? suggestedTimeoutMs,
|
int? suggestedTimeoutMs,
|
||||||
}) {
|
}) {
|
||||||
|
int baseTimeout;
|
||||||
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
if (suggestedTimeoutMs != null && suggestedTimeoutMs > 0) {
|
||||||
return suggestedTimeoutMs;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final payloadBytes = utf8.encode(text).length;
|
// Add random jitter: 500-2000ms to avoid collision
|
||||||
final airtimeMs = _estimateLoRaAirtimeMs(payloadBytes);
|
final jitterMs = 500 + _rng.nextInt(1501);
|
||||||
final hopCount = contact?.routeHasPath == true
|
return baseTimeout + jitterMs;
|
||||||
? contact!.routeHopCount
|
}
|
||||||
: -1;
|
|
||||||
|
|
||||||
if (hopCount < 0) {
|
/// Max attempts for a given contact based on whether it has a known path.
|
||||||
return ((airtimeMs * 10) + 4000).clamp(10000, 30000);
|
static int maxRetryAttemptsForContact(Contact? contact) {
|
||||||
}
|
final hasPath = contact?.routeHasPath ?? false;
|
||||||
|
return hasPath ? maxRetryAttemptsWithPath : maxRetryAttemptsFloodOnly;
|
||||||
return ((airtimeMs * (hopCount + 1) * 2) + 1500).clamp(4000, 20000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool canRetry(Message message, Contact contact) {
|
bool canRetry(Message message, Contact contact) {
|
||||||
return message.retryAttempt < maxRetryAttempts;
|
return message.retryAttempt < maxRetryAttemptsForContact(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this is the last retry attempt.
|
||||||
|
/// When true, the caller should reset the path to force flood mode.
|
||||||
|
///
|
||||||
|
/// Note: called AFTER retryAttempt has been incremented, so we compare
|
||||||
|
/// directly against maxAttempts (not +1).
|
||||||
|
bool isLastAttempt(Message message, Contact contact) {
|
||||||
|
final maxAttempts = maxRetryAttemptsForContact(contact);
|
||||||
|
return maxAttempts > 1 && message.retryAttempt >= maxAttempts;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Track a retry attempt for a message
|
/// Track a retry attempt for a message
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!requester.routeSupportsLegacyRawTransport) {
|
|
||||||
debugPrint(
|
|
||||||
'⚠️ [$providerLabel] ${requester.advName} route uses unsupported 3-byte raw transport on current client',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (requester.outPath.isEmpty) {
|
if (requester.outPath.isEmpty) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
'⚠️ [$providerLabel] ${requester.advName} has empty outPath payload',
|
||||||
@@ -64,7 +58,7 @@ Future<bool> serveCachedSessionFragments<T>({
|
|||||||
try {
|
try {
|
||||||
await sendRawPacket(
|
await sendRawPacket(
|
||||||
contactPath: requester.outPath,
|
contactPath: requester.outPath,
|
||||||
contactPathLen: requester.routeSignedPathLen,
|
contactPathLen: requester.routeEncodedPathLen,
|
||||||
payload: encodeBinary(fragment),
|
payload: encodeBinary(fragment),
|
||||||
);
|
);
|
||||||
servedCount++;
|
servedCount++;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 'helpers/raw_session_retransmit.dart';
|
||||||
import '../utils/image_message_parser.dart';
|
import '../utils/image_message_parser.dart';
|
||||||
|
|
||||||
@@ -75,6 +76,8 @@ 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];
|
||||||
@@ -292,18 +295,22 @@ class ImageProvider with ChangeNotifier {
|
|||||||
// ── Persistence ──────────────────────────────────────────────────────────
|
// ── Persistence ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> clearAll() async {
|
Future<void> clearAll() async {
|
||||||
_sessions.clear();
|
_resetInMemoryState();
|
||||||
_outgoing.clear();
|
|
||||||
_ignoredIncomingSessions.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);
|
||||||
@@ -343,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');
|
||||||
}
|
}
|
||||||
@@ -352,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>;
|
||||||
@@ -428,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
@@ -5,6 +5,8 @@ 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 '../widgets/sensors/sensor_telemetry_card.dart';
|
||||||
import 'connection_provider.dart';
|
import 'connection_provider.dart';
|
||||||
import 'contacts_provider.dart';
|
import 'contacts_provider.dart';
|
||||||
|
|
||||||
@@ -12,9 +14,23 @@ enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
|
|||||||
|
|
||||||
class SensorsProvider with ChangeNotifier {
|
class SensorsProvider with ChangeNotifier {
|
||||||
static const Duration _successStateRetention = Duration(minutes: 1);
|
static const Duration _successStateRetention = Duration(minutes: 1);
|
||||||
|
static const Duration selfAutoRefreshInterval = Duration(seconds: 30);
|
||||||
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
static const String _watchedSensorsKey = 'watched_sensor_keys';
|
||||||
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
|
||||||
static const String _fieldSpanKey = 'sensor_field_spans';
|
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>{
|
static const Set<String> _defaultVisibleFields = <String>{
|
||||||
'voltage',
|
'voltage',
|
||||||
'battery',
|
'battery',
|
||||||
@@ -23,6 +39,14 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
'pressure',
|
'pressure',
|
||||||
'gps',
|
'gps',
|
||||||
};
|
};
|
||||||
|
static const List<String> _defaultMetricOrder = <String>[
|
||||||
|
'voltage',
|
||||||
|
'battery',
|
||||||
|
'temperature',
|
||||||
|
'humidity',
|
||||||
|
'pressure',
|
||||||
|
'gps',
|
||||||
|
];
|
||||||
|
|
||||||
final List<String> _watchedSensorKeys = <String>[];
|
final List<String> _watchedSensorKeys = <String>[];
|
||||||
final Map<String, SensorRefreshState> _refreshStates =
|
final Map<String, SensorRefreshState> _refreshStates =
|
||||||
@@ -32,13 +56,22 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
<String, Set<String>>{};
|
<String, Set<String>>{};
|
||||||
final Map<String, Map<String, int>> _fieldSpansBySensor =
|
final Map<String, Map<String, int>> _fieldSpansBySensor =
|
||||||
<String, Map<String, int>>{};
|
<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 _isLoaded = false;
|
||||||
bool _isRefreshingAll = false;
|
bool _isRefreshingAll = false;
|
||||||
|
bool _isRunningAutoRefreshTick = false;
|
||||||
|
|
||||||
SensorsProvider() {
|
SensorsProvider() {
|
||||||
unawaited(_loadWatchedSensors());
|
unawaited(_loadWatchedSensors());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _key(String baseKey) => ProfileStorageScope.scopedKey(baseKey);
|
||||||
|
|
||||||
List<String> get watchedSensorKeys => List.unmodifiable(_watchedSensorKeys);
|
List<String> get watchedSensorKeys => List.unmodifiable(_watchedSensorKeys);
|
||||||
bool get isLoaded => _isLoaded;
|
bool get isLoaded => _isLoaded;
|
||||||
bool get isRefreshingAll => _isRefreshingAll;
|
bool get isRefreshingAll => _isRefreshingAll;
|
||||||
@@ -49,14 +82,26 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
Future<void> _loadWatchedSensors() async {
|
Future<void> _loadWatchedSensors() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final stored = prefs.getStringList(_watchedSensorsKey) ?? <String>[];
|
final stored =
|
||||||
final storedMetricsJson = prefs.getString(_visibleSensorMetricsKey);
|
prefs.getStringList(_key(_watchedSensorsKey)) ?? <String>[];
|
||||||
final storedSpansJson = prefs.getString(_fieldSpanKey);
|
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
|
_watchedSensorKeys
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(stored);
|
..addAll(stored);
|
||||||
|
_refreshStates.clear();
|
||||||
|
_refreshStateUpdatedAt.clear();
|
||||||
_visibleFieldsBySensor.clear();
|
_visibleFieldsBySensor.clear();
|
||||||
_fieldSpansBySensor.clear();
|
_fieldSpansBySensor.clear();
|
||||||
|
_metricLabelsBySensor.clear();
|
||||||
|
_metricOrderBySensor.clear();
|
||||||
|
_autoRefreshMinutesBySensor.clear();
|
||||||
|
_lastRefreshAttemptAt.clear();
|
||||||
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
|
||||||
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
|
||||||
for (final entry in decoded.entries) {
|
for (final entry in decoded.entries) {
|
||||||
@@ -72,12 +117,49 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
.map((key, value) => MapEntry(key, value as int));
|
.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) {
|
for (final key in _watchedSensorKeys) {
|
||||||
_visibleFieldsBySensor.putIfAbsent(
|
_visibleFieldsBySensor.putIfAbsent(
|
||||||
key,
|
key,
|
||||||
() => Set<String>.from(_defaultVisibleFields),
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
);
|
);
|
||||||
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
|
_fieldSpansBySensor.putIfAbsent(key, () => <String, int>{});
|
||||||
|
_metricLabelsBySensor.putIfAbsent(key, () => <String, String>{});
|
||||||
|
_metricOrderBySensor.putIfAbsent(
|
||||||
|
key,
|
||||||
|
() => List<String>.from(_defaultMetricOrder),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error loading watched sensors: $e');
|
debugPrint('Error loading watched sensors: $e');
|
||||||
@@ -87,10 +169,15 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> reloadProfileScopedState() async {
|
||||||
|
_isLoaded = false;
|
||||||
|
await _loadWatchedSensors();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _persistWatchedSensors() async {
|
Future<void> _persistWatchedSensors() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setStringList(_watchedSensorsKey, _watchedSensorKeys);
|
await prefs.setStringList(_key(_watchedSensorsKey), _watchedSensorKeys);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error saving watched sensors: $e');
|
debugPrint('Error saving watched sensors: $e');
|
||||||
}
|
}
|
||||||
@@ -103,7 +190,10 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
for (final entry in _visibleFieldsBySensor.entries) {
|
for (final entry in _visibleFieldsBySensor.entries) {
|
||||||
encoded[entry.key] = entry.value.toList();
|
encoded[entry.key] = entry.value.toList();
|
||||||
}
|
}
|
||||||
await prefs.setString(_visibleSensorMetricsKey, jsonEncode(encoded));
|
await prefs.setString(
|
||||||
|
_key(_visibleSensorMetricsKey),
|
||||||
|
jsonEncode(encoded),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error saving visible sensor metrics: $e');
|
debugPrint('Error saving visible sensor metrics: $e');
|
||||||
}
|
}
|
||||||
@@ -112,16 +202,71 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
Future<void> _persistFieldSpans() async {
|
Future<void> _persistFieldSpans() async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setString(_fieldSpanKey, jsonEncode(_fieldSpansBySensor));
|
await prefs.setString(
|
||||||
|
_key(_fieldSpanKey),
|
||||||
|
jsonEncode(_fieldSpansBySensor),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Error saving sensor field spans: $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(
|
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
|
||||||
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
|
_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) =>
|
bool showsField(String publicKeyHex, String fieldKey) =>
|
||||||
visibleFieldsFor(publicKeyHex).contains(fieldKey);
|
visibleFieldsFor(publicKeyHex).contains(fieldKey);
|
||||||
|
|
||||||
@@ -131,6 +276,118 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
return span == 2 ? 2 : 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(
|
Future<void> toggleMetric(
|
||||||
String publicKeyHex,
|
String publicKeyHex,
|
||||||
String fieldKey,
|
String fieldKey,
|
||||||
@@ -140,8 +397,17 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
publicKeyHex,
|
publicKeyHex,
|
||||||
() => Set<String>.from(_defaultVisibleFields),
|
() => Set<String>.from(_defaultVisibleFields),
|
||||||
);
|
);
|
||||||
|
final metricOrder = _metricOrderBySensor.putIfAbsent(
|
||||||
|
publicKeyHex,
|
||||||
|
() => List<String>.from(_defaultMetricOrder),
|
||||||
|
);
|
||||||
|
var shouldPersistOrder = false;
|
||||||
if (visible) {
|
if (visible) {
|
||||||
visibleFields.add(fieldKey);
|
visibleFields.add(fieldKey);
|
||||||
|
if (!metricOrder.contains(fieldKey)) {
|
||||||
|
metricOrder.add(fieldKey);
|
||||||
|
shouldPersistOrder = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
|
if (visibleFields.length == 1 && visibleFields.contains(fieldKey)) {
|
||||||
return;
|
return;
|
||||||
@@ -149,6 +415,9 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
visibleFields.remove(fieldKey);
|
visibleFields.remove(fieldKey);
|
||||||
}
|
}
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
|
if (shouldPersistOrder) {
|
||||||
|
await _persistMetricOrder();
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,11 +435,84 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
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) =>
|
bool isWatched(String publicKeyHex) =>
|
||||||
_watchedSensorKeys.contains(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 {
|
Future<void> addSensor(Contact contact) async {
|
||||||
if (!contact.isChat && !contact.isRepeater) {
|
if (!contact.isChat && !contact.isRepeater && !contact.isSensor) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
|
if (_watchedSensorKeys.contains(contact.publicKeyHex)) {
|
||||||
@@ -178,43 +520,183 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_watchedSensorKeys.add(contact.publicKeyHex);
|
_watchedSensorKeys.add(contact.publicKeyHex);
|
||||||
|
final initialVisibleFields = _initialVisibleFieldsForContact(contact);
|
||||||
await _persistWatchedSensors();
|
await _persistWatchedSensors();
|
||||||
_visibleFieldsBySensor[contact.publicKeyHex] = Set<String>.from(
|
_visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields;
|
||||||
_defaultVisibleFields,
|
|
||||||
);
|
|
||||||
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
_fieldSpansBySensor[contact.publicKeyHex] = <String, int>{'gps': 2};
|
||||||
|
_metricLabelsBySensor[contact.publicKeyHex] = <String, String>{};
|
||||||
|
_metricOrderBySensor[contact.publicKeyHex] = metricOrderFor(
|
||||||
|
contact.publicKeyHex,
|
||||||
|
initialVisibleFields,
|
||||||
|
);
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
await _persistFieldSpans();
|
await _persistFieldSpans();
|
||||||
|
await _persistMetricLabels();
|
||||||
|
await _persistMetricOrder();
|
||||||
notifyListeners();
|
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 {
|
Future<void> removeSensor(String publicKeyHex) async {
|
||||||
_watchedSensorKeys.remove(publicKeyHex);
|
_watchedSensorKeys.remove(publicKeyHex);
|
||||||
_refreshStates.remove(publicKeyHex);
|
_refreshStates.remove(publicKeyHex);
|
||||||
_refreshStateUpdatedAt.remove(publicKeyHex);
|
_refreshStateUpdatedAt.remove(publicKeyHex);
|
||||||
_visibleFieldsBySensor.remove(publicKeyHex);
|
_visibleFieldsBySensor.remove(publicKeyHex);
|
||||||
_fieldSpansBySensor.remove(publicKeyHex);
|
_fieldSpansBySensor.remove(publicKeyHex);
|
||||||
|
_metricLabelsBySensor.remove(publicKeyHex);
|
||||||
|
_metricOrderBySensor.remove(publicKeyHex);
|
||||||
|
_autoRefreshMinutesBySensor.remove(publicKeyHex);
|
||||||
|
_lastRefreshAttemptAt.remove(publicKeyHex);
|
||||||
await _persistWatchedSensors();
|
await _persistWatchedSensors();
|
||||||
await _persistVisibleMetrics();
|
await _persistVisibleMetrics();
|
||||||
await _persistFieldSpans();
|
await _persistFieldSpans();
|
||||||
|
await _persistMetricLabels();
|
||||||
|
await _persistMetricOrder();
|
||||||
|
await _persistAutoRefreshMinutes();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Contact> availableCandidates(ContactsProvider contactsProvider) {
|
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>[
|
final candidates = <Contact>[
|
||||||
...contactsProvider.chatContacts,
|
...contactsProvider.chatContacts,
|
||||||
...contactsProvider.repeaters,
|
...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.removeWhere((contact) => isWatched(contact.publicKeyHex));
|
||||||
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
|
||||||
return candidates;
|
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({
|
Future<void> refreshAll({
|
||||||
required ContactsProvider contactsProvider,
|
required ContactsProvider contactsProvider,
|
||||||
required ConnectionProvider connectionProvider,
|
required ConnectionProvider connectionProvider,
|
||||||
}) async {
|
}) async {
|
||||||
if (_isRefreshingAll || _watchedSensorKeys.isEmpty) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,7 +704,7 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (final key in _watchedSensorKeys) {
|
for (final key in keysToRefresh) {
|
||||||
await refreshSensor(
|
await refreshSensor(
|
||||||
publicKeyHex: key,
|
publicKeyHex: key,
|
||||||
contactsProvider: contactsProvider,
|
contactsProvider: contactsProvider,
|
||||||
@@ -239,15 +721,20 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
required String publicKeyHex,
|
required String publicKeyHex,
|
||||||
required ContactsProvider contactsProvider,
|
required ContactsProvider contactsProvider,
|
||||||
required ConnectionProvider connectionProvider,
|
required ConnectionProvider connectionProvider,
|
||||||
|
DateTime? requestedAt,
|
||||||
}) async {
|
}) async {
|
||||||
Contact? contact;
|
if (stateFor(publicKeyHex) == SensorRefreshState.refreshing) {
|
||||||
for (final entry in contactsProvider.contacts) {
|
return;
|
||||||
if (entry.publicKeyHex == publicKeyHex) {
|
|
||||||
contact = entry;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
|
||||||
|
|
||||||
|
final contact = contactForDisplay(
|
||||||
|
publicKeyHex,
|
||||||
|
contactsProvider: contactsProvider,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
);
|
||||||
|
|
||||||
if (contact == null) {
|
if (contact == null) {
|
||||||
_setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
|
_setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
|
||||||
return;
|
return;
|
||||||
@@ -266,6 +753,48 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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}) {
|
void clearExpiredRefreshStates({DateTime? now}) {
|
||||||
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
|
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
|
||||||
final keysToClear = <String>[];
|
final keysToClear = <String>[];
|
||||||
@@ -297,4 +826,29 @@ class SensorsProvider with ChangeNotifier {
|
|||||||
_refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
|
_refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
|
||||||
notifyListeners();
|
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,10 @@
|
|||||||
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 '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';
|
||||||
@@ -91,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];
|
||||||
@@ -299,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) {
|
||||||
@@ -319,20 +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();
|
|
||||||
_ignoredIncomingSessions.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();
|
||||||
@@ -356,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');
|
||||||
}
|
}
|
||||||
@@ -365,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>;
|
||||||
@@ -428,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
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -6,24 +8,31 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'package:vibration/vibration.dart';
|
import 'package:vibration/vibration.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/app_provider.dart';
|
import '../providers/app_provider.dart';
|
||||||
import '../models/device_info.dart' show ConnectionMode;
|
import '../models/device_info.dart' show ConnectionMode, DeviceInfo;
|
||||||
import '../providers/messages_provider.dart';
|
import '../providers/messages_provider.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'messages_tab.dart';
|
import 'messages_tab.dart';
|
||||||
import 'contacts_tab.dart';
|
import 'contacts_tab.dart';
|
||||||
|
import 'discovery_screen.dart';
|
||||||
import 'sensors_tab.dart';
|
import 'sensors_tab.dart';
|
||||||
import 'map_tab.dart';
|
import 'map_tab.dart';
|
||||||
|
import 'repeaters_map_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'device_config_screen.dart';
|
import 'device_config_screen.dart';
|
||||||
import 'packet_log_screen.dart';
|
import 'packet_log_screen.dart';
|
||||||
import 'spectrum_scan_screen.dart';
|
import 'live_traffic_screen.dart';
|
||||||
|
import 'profiles_screen.dart';
|
||||||
import '../utils/toast_logger.dart';
|
import '../utils/toast_logger.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/permission_request_dialog.dart';
|
import '../widgets/permission_request_dialog.dart';
|
||||||
import '../widgets/connection_dialog.dart';
|
import '../widgets/connection_dialog.dart';
|
||||||
import '../utils/battery_display_helper.dart';
|
import '../utils/battery_display_helper.dart';
|
||||||
import '../services/developer_mode_service.dart';
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
|
import '../services/profile_device_key_resolver.dart';
|
||||||
|
import '../services/profile_manager.dart';
|
||||||
|
import '../services/profile_workspace_coordinator.dart';
|
||||||
|
import '../services/profiles_feature_service.dart';
|
||||||
|
|
||||||
enum _HomeTab { messages, contacts, sensors, map }
|
enum _HomeTab { messages, contacts, sensors, map }
|
||||||
|
|
||||||
@@ -53,14 +62,15 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
with TickerProviderStateMixin, WidgetsBindingObserver {
|
with TickerProviderStateMixin, WidgetsBindingObserver {
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
late final AppProvider _appProvider;
|
late final AppProvider _appProvider;
|
||||||
|
late final ConnectionProvider _connectionProvider;
|
||||||
int _currentIndex = 0;
|
int _currentIndex = 0;
|
||||||
bool _isMapFullscreen = false;
|
bool _isMapFullscreen = false;
|
||||||
bool _showRxTxIndicators = true;
|
bool _showRxTxIndicators = true;
|
||||||
bool _isDeveloperModeEnabled = false;
|
|
||||||
bool _isMapEnabled = true;
|
bool _isMapEnabled = true;
|
||||||
bool _isContactsEnabled = true;
|
bool _isContactsEnabled = true;
|
||||||
bool _isSensorsEnabled = false;
|
bool _isSensorsEnabled = false;
|
||||||
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
|
||||||
|
String? _lastProfileDeviceKey;
|
||||||
|
|
||||||
List<_HomeTab> get _enabledTabs {
|
List<_HomeTab> get _enabledTabs {
|
||||||
return [
|
return [
|
||||||
@@ -84,6 +94,8 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_appProvider = context.read<AppProvider>();
|
_appProvider = context.read<AppProvider>();
|
||||||
|
_connectionProvider = context.read<ConnectionProvider>();
|
||||||
|
_connectionProvider.addListener(_handleConnectionProviderChanged);
|
||||||
_isMapEnabled = _appProvider.isMapEnabled;
|
_isMapEnabled = _appProvider.isMapEnabled;
|
||||||
_isContactsEnabled = _appProvider.isContactsEnabled;
|
_isContactsEnabled = _appProvider.isContactsEnabled;
|
||||||
_isSensorsEnabled = _appProvider.isSensorsEnabled;
|
_isSensorsEnabled = _appProvider.isSensorsEnabled;
|
||||||
@@ -92,7 +104,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
// Initialize synchronously so first build always has a valid controller.
|
// Initialize synchronously so first build always has a valid controller.
|
||||||
_initTabController();
|
_initTabController();
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
_loadDeveloperModePreference();
|
MeshMapNodesService.syncInBackgroundIfStale();
|
||||||
|
|
||||||
// Show permission dialog after the first frame if needed
|
// Show permission dialog after the first frame if needed
|
||||||
if (widget.shouldShowPermissionDialog) {
|
if (widget.shouldShowPermissionDialog) {
|
||||||
@@ -100,6 +112,10 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
_showPermissionDialog();
|
_showPermissionDialog();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_handleConnectionProviderChanged();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initTabController() {
|
void _initTabController() {
|
||||||
@@ -195,7 +211,6 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
case _HomeTab.messages:
|
case _HomeTab.messages:
|
||||||
context.read<MessagesProvider>().markAllAsRead();
|
|
||||||
break;
|
break;
|
||||||
case _HomeTab.contacts:
|
case _HomeTab.contacts:
|
||||||
context.read<ContactsProvider>().markAllAsViewed();
|
context.read<ContactsProvider>().markAllAsViewed();
|
||||||
@@ -216,31 +231,28 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
void _handleConnectionProviderChanged() {
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
final deviceKey = ProfileDeviceKeyResolver.resolve(
|
||||||
_lifecycleState = state;
|
deviceInfo: _connectionProvider.deviceInfo,
|
||||||
_syncFastLocationUiState();
|
connectionMode: _connectionProvider.connectionMode,
|
||||||
}
|
);
|
||||||
|
if (_lastProfileDeviceKey == deviceKey) {
|
||||||
Future<void> _loadRxTxPreference() async {
|
return;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_showRxTxIndicators = prefs.getBool('show_rx_tx_indicators') ?? true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
_lastProfileDeviceKey = deviceKey;
|
||||||
|
if (deviceKey == null || !mounted) {
|
||||||
Future<void> _loadDeveloperModePreference() async {
|
return;
|
||||||
final isEnabled = await DeveloperModeService.isEnabled();
|
}
|
||||||
if (!mounted) return;
|
unawaited(
|
||||||
setState(() {
|
context
|
||||||
_isDeveloperModeEnabled = isEnabled;
|
.read<ProfileWorkspaceCoordinator>()
|
||||||
});
|
.syncActiveProfileForCurrentDevice(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_connectionProvider.removeListener(_handleConnectionProviderChanged);
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_appProvider.setFastLocationUiActive(false);
|
_appProvider.setFastLocationUiActive(false);
|
||||||
_appProvider.removeListener(_handleAppProviderChanged);
|
_appProvider.removeListener(_handleAppProviderChanged);
|
||||||
@@ -249,6 +261,32 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openLiveTraffic(ConnectionProvider provider) {
|
||||||
|
openLiveTrafficScreen(context, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
_lifecycleState = state;
|
||||||
|
_syncFastLocationUiState();
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
MeshMapNodesService.syncInBackgroundIfStale();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadRxTxPreference() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_showRxTxIndicators =
|
||||||
|
prefs.getBool(
|
||||||
|
ProfileStorageScope.scopedKey('show_rx_tx_indicators'),
|
||||||
|
) ??
|
||||||
|
true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showPermissionDialog() {
|
void _showPermissionDialog() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
@@ -344,7 +382,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(l10n.flood),
|
title: Text(l10n.flood),
|
||||||
subtitle: const Text('Relay through repeaters across the mesh'),
|
subtitle: Text(l10n.relayThroughRepeatersAcrossTheMesh),
|
||||||
trailing: const Icon(Icons.chevron_right_rounded),
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -370,7 +408,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(l10n.direct),
|
title: Text(l10n.direct),
|
||||||
subtitle: const Text('Nearby only, without repeater flooding'),
|
subtitle: Text(l10n.nearbyOnlyWithoutRepeaterFlooding),
|
||||||
trailing: const Icon(Icons.chevron_right_rounded),
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@@ -384,41 +422,184 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildActivityBadge({
|
void _showDeviceInfoSheet(BuildContext context, DeviceInfo deviceInfo) {
|
||||||
required String label,
|
// Request self telemetry so it's fresh
|
||||||
required int count,
|
context.read<ConnectionProvider>().requestSelfTelemetry();
|
||||||
required bool isActive,
|
|
||||||
required Color activeColor,
|
showModalBottomSheet(
|
||||||
}) {
|
context: context,
|
||||||
final color = isActive ? activeColor : Colors.grey;
|
shape: const RoundedRectangleBorder(
|
||||||
return Container(
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: color.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
builder: (context) => Consumer<ContactsProvider>(
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, contactsProvider, child) {
|
||||||
children: [
|
final telemetry = contactsProvider.selfTelemetry;
|
||||||
Container(
|
|
||||||
width: 7,
|
return SafeArea(
|
||||||
height: 7,
|
child: Padding(
|
||||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
padding: const EdgeInsets.all(20),
|
||||||
),
|
child: Column(
|
||||||
const SizedBox(width: 6),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Text(
|
children: [
|
||||||
'$label:$count',
|
Container(
|
||||||
style: TextStyle(
|
width: 40,
|
||||||
fontSize: 11,
|
height: 4,
|
||||||
fontWeight: FontWeight.w600,
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: Theme.of(context).dividerColor,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
deviceInfo.selfName ?? deviceInfo.deviceName ?? 'Device',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_deviceInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.bluetooth,
|
||||||
|
'BLE Signal',
|
||||||
|
deviceInfo.signalRssi != null
|
||||||
|
? '${deviceInfo.signalRssi} dBm'
|
||||||
|
: 'N/A',
|
||||||
|
),
|
||||||
|
if (deviceInfo.batteryPercent != null)
|
||||||
|
_deviceInfoRow(
|
||||||
|
context,
|
||||||
|
BatteryDisplayHelper.getBatteryIcon(
|
||||||
|
deviceInfo.batteryPercent!,
|
||||||
|
),
|
||||||
|
'Battery',
|
||||||
|
'${deviceInfo.batteryPercent!.round()}%',
|
||||||
|
),
|
||||||
|
if (deviceInfo.batteryMilliVolts != null)
|
||||||
|
_deviceInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.bolt,
|
||||||
|
'Voltage',
|
||||||
|
'${(deviceInfo.batteryMilliVolts! / 1000).toStringAsFixed(2)}V',
|
||||||
|
),
|
||||||
|
if (deviceInfo.storageUsedKb != null &&
|
||||||
|
deviceInfo.storageTotalKb != null)
|
||||||
|
_deviceInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.storage,
|
||||||
|
'Storage',
|
||||||
|
'${deviceInfo.storageUsedKb} / ${deviceInfo.storageTotalKb} KB',
|
||||||
|
),
|
||||||
|
if (deviceInfo.firmwareVersion != null)
|
||||||
|
_deviceInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.system_update,
|
||||||
|
'Firmware',
|
||||||
|
'v${deviceInfo.firmwareVersion}',
|
||||||
|
),
|
||||||
|
if (deviceInfo.radioFreq != null)
|
||||||
|
_deviceInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.radio,
|
||||||
|
'Frequency',
|
||||||
|
'${(deviceInfo.radioFreq! / 1000).toStringAsFixed(3)} MHz',
|
||||||
|
),
|
||||||
|
if (deviceInfo.txPower != null)
|
||||||
|
_deviceInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.power,
|
||||||
|
'TX Power',
|
||||||
|
'${deviceInfo.txPower} dBm',
|
||||||
|
),
|
||||||
|
if (telemetry != null) ...[
|
||||||
|
const Divider(height: 24),
|
||||||
|
Text(
|
||||||
|
'Self Telemetry',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (telemetry.temperature != null)
|
||||||
|
_deviceInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.thermostat,
|
||||||
|
'Temperature',
|
||||||
|
'${telemetry.temperature!.toStringAsFixed(1)}°C',
|
||||||
|
),
|
||||||
|
if (telemetry.humidity != null)
|
||||||
|
_deviceInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.water_drop,
|
||||||
|
'Humidity',
|
||||||
|
'${telemetry.humidity!.toStringAsFixed(1)}%',
|
||||||
|
),
|
||||||
|
if (telemetry.pressure != null)
|
||||||
|
_deviceInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.compress,
|
||||||
|
'Pressure',
|
||||||
|
'${telemetry.pressure!.toStringAsFixed(1)} hPa',
|
||||||
|
),
|
||||||
|
if (telemetry.gpsLocation != null)
|
||||||
|
_deviceInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.gps_fixed,
|
||||||
|
'GPS',
|
||||||
|
'${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _deviceInfoRow(
|
||||||
|
BuildContext context,
|
||||||
|
IconData icon,
|
||||||
|
String label,
|
||||||
|
String value,
|
||||||
|
) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMiniSignalBars({required int activeBars, required Color color}) {
|
||||||
|
final inactive = Colors.grey.withValues(alpha: 0.3);
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < 5; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(width: 1.5),
|
||||||
|
Container(
|
||||||
|
width: 2.5,
|
||||||
|
height: 4.0 + (i * 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: i < activeBars ? color : inactive,
|
||||||
|
borderRadius: BorderRadius.circular(1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildCompactActivityIndicator({
|
Widget _buildCompactActivityIndicator({
|
||||||
required bool rxActive,
|
required bool rxActive,
|
||||||
required bool txActive,
|
required bool txActive,
|
||||||
@@ -556,12 +737,10 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showConnectionDialog(BuildContext context) {
|
Future<void> _showConnectionDialog(BuildContext context) async {
|
||||||
showModalBottomSheet(
|
await showConnectionDialogFlow(
|
||||||
context: context,
|
context,
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (context) => const ConnectionDialog(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,6 +754,9 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.watch<AppProvider>();
|
context.watch<AppProvider>();
|
||||||
|
final activeProfileId = context.select<ProfileManager, String>(
|
||||||
|
(manager) => manager.activeProfileId,
|
||||||
|
);
|
||||||
|
|
||||||
final enabledTabs = _enabledTabs;
|
final enabledTabs = _enabledTabs;
|
||||||
final isMapTabActive = _currentTab == _HomeTab.map;
|
final isMapTabActive = _currentTab == _HomeTab.map;
|
||||||
@@ -599,7 +781,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await provider.disconnect();
|
await provider.disconnect();
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.power_settings_new),
|
icon: Icon(Icons.power_settings_new),
|
||||||
tooltip: AppLocalizations.of(context)!.disconnect,
|
tooltip: AppLocalizations.of(context)!.disconnect,
|
||||||
color: Colors.red.shade700,
|
color: Colors.red.shade700,
|
||||||
);
|
);
|
||||||
@@ -611,39 +793,109 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
final items = <PopupMenuEntry<void>>[];
|
final items = <PopupMenuEntry<void>>[];
|
||||||
|
final profilesEnabled = context
|
||||||
|
.read<ProfileManager>()
|
||||||
|
.profilesEnabled;
|
||||||
|
|
||||||
if (_isDeveloperModeEnabled) {
|
items.add(
|
||||||
items.add(
|
PopupMenuItem(
|
||||||
PopupMenuItem(
|
child: Row(
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
const Icon(Icons.radar_outlined),
|
||||||
const Icon(Icons.radar),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
Text(AppLocalizations.of(context)!.liveTraffic),
|
||||||
const Text('Spectrum Scan'),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
Future.delayed(Duration.zero, () {
|
|
||||||
if (!mounted) return;
|
|
||||||
navigator.push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) =>
|
|
||||||
const SpectrumScanScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
onTap: () {
|
||||||
}
|
final navigator = Navigator.of(context);
|
||||||
|
final provider = context.read<ConnectionProvider>();
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => LiveTrafficScreen.fromProvider(
|
||||||
|
provider,
|
||||||
|
openPacketLogs: () {
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => PacketLogScreen(
|
||||||
|
bleService: provider.bleService,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.router_outlined),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(AppLocalizations.of(context)!.repeatersMap),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) =>
|
||||||
|
const RepeatersMapScreen(),
|
||||||
|
fullscreenDialog: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.person_search),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Consumer<ContactsProvider>(
|
||||||
|
builder: (context, contactsProvider, child) {
|
||||||
|
final pendingCount =
|
||||||
|
contactsProvider.pendingAdverts.length;
|
||||||
|
return Text(
|
||||||
|
pendingCount > 0
|
||||||
|
? 'Discovery ($pendingCount)'
|
||||||
|
: 'Discovery',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const DiscoveryScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
items.add(
|
items.add(
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.settings),
|
const Icon(Icons.settings),
|
||||||
const SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Text(AppLocalizations.of(context)!.settings),
|
Text(AppLocalizations.of(context)!.settings),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -662,12 +914,36 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
_loadRxTxPreference();
|
_loadRxTxPreference();
|
||||||
_loadDeveloperModePreference();
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (profilesEnabled) {
|
||||||
|
items.add(
|
||||||
|
PopupMenuItem(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.layers_outlined),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(AppLocalizations.of(context)!.profiles),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
Future.delayed(Duration.zero, () {
|
||||||
|
if (!mounted) return;
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const ProfilesScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -679,6 +955,9 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
switch (tab) {
|
switch (tab) {
|
||||||
case _HomeTab.messages:
|
case _HomeTab.messages:
|
||||||
return MessagesTab(
|
return MessagesTab(
|
||||||
|
isActive:
|
||||||
|
_currentTab == _HomeTab.messages &&
|
||||||
|
_lifecycleState == AppLifecycleState.resumed,
|
||||||
onNavigateToMap: _isMapEnabled
|
onNavigateToMap: _isMapEnabled
|
||||||
? () => _navigateToTab(_HomeTab.map)
|
? () => _navigateToTab(_HomeTab.map)
|
||||||
: null,
|
: null,
|
||||||
@@ -691,9 +970,14 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
|
onNavigateToMessages: () => _navigateToTab(_HomeTab.messages),
|
||||||
);
|
);
|
||||||
case _HomeTab.sensors:
|
case _HomeTab.sensors:
|
||||||
return const SensorsTab();
|
return SensorsTab(
|
||||||
|
isActive:
|
||||||
|
_currentTab == _HomeTab.sensors &&
|
||||||
|
_lifecycleState == AppLifecycleState.resumed,
|
||||||
|
);
|
||||||
case _HomeTab.map:
|
case _HomeTab.map:
|
||||||
return MapTab(
|
return MapTab(
|
||||||
|
key: ValueKey<String>('map:$activeProfileId'),
|
||||||
onFullscreenChanged: (isFullscreen) {
|
onFullscreenChanged: (isFullscreen) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isMapFullscreen = isFullscreen;
|
_isMapFullscreen = isFullscreen;
|
||||||
@@ -738,7 +1022,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
Icons.message,
|
Icons.message,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
),
|
),
|
||||||
text: AppLocalizations.of(context)!.messages,
|
text: 'Chat',
|
||||||
);
|
);
|
||||||
case _HomeTab.contacts:
|
case _HomeTab.contacts:
|
||||||
return Tab(
|
return Tab(
|
||||||
@@ -750,7 +1034,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
);
|
);
|
||||||
case _HomeTab.map:
|
case _HomeTab.map:
|
||||||
return Tab(
|
return Tab(
|
||||||
icon: const Icon(Icons.map),
|
icon: Icon(Icons.map),
|
||||||
text: AppLocalizations.of(context)!.map,
|
text: AppLocalizations.of(context)!.map,
|
||||||
);
|
);
|
||||||
case _HomeTab.sensors:
|
case _HomeTab.sensors:
|
||||||
@@ -775,19 +1059,35 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
|
final isTcpConnected = provider.connectionMode == ConnectionMode.tcp;
|
||||||
|
|
||||||
if (!isConnected) {
|
if (!isConnected) {
|
||||||
// Disconnected state: show connect button
|
final buttonLabel = provider.isReconnecting
|
||||||
|
? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}'
|
||||||
|
: AppLocalizations.of(context)!.connect;
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
AppLocalizations.of(context)!.appTitle,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: const TextStyle(
|
mainAxisSize: MainAxisSize.min,
|
||||||
fontSize: 20,
|
children: [
|
||||||
fontWeight: FontWeight.bold,
|
Text(
|
||||||
),
|
AppLocalizations.of(context)!.appTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
provider.isReconnecting
|
||||||
|
? 'Restoring previous link'
|
||||||
|
: 'No device connected',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: provider.isReconnecting
|
onPressed: provider.isReconnecting
|
||||||
? null
|
? null
|
||||||
: () => _showConnectionDialog(context),
|
: () => _showConnectionDialog(context),
|
||||||
@@ -798,22 +1098,19 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
Colors.black54,
|
Colors.white70,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.bluetooth, size: 18),
|
: const Icon(Icons.add_link_rounded, size: 18),
|
||||||
label: Text(
|
label: Text(buttonLabel),
|
||||||
provider.isReconnecting
|
style: FilledButton.styleFrom(
|
||||||
? '${provider.reconnectionAttempt}/${provider.maxReconnectionAttempts}'
|
padding: const EdgeInsets.symmetric(
|
||||||
: AppLocalizations.of(context)!.connect,
|
horizontal: 16,
|
||||||
),
|
vertical: 12,
|
||||||
style: ElevatedButton.styleFrom(
|
),
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: Colors.black87,
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -821,7 +1118,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: () => provider.cancelReconnection(),
|
onPressed: () => provider.cancelReconnection(),
|
||||||
icon: const Icon(Icons.close, size: 20),
|
icon: Icon(Icons.close, size: 20),
|
||||||
tooltip: AppLocalizations.of(context)!.cancelReconnection,
|
tooltip: AppLocalizations.of(context)!.cancelReconnection,
|
||||||
style: IconButton.styleFrom(
|
style: IconButton.styleFrom(
|
||||||
backgroundColor: Colors.red.shade700,
|
backgroundColor: Colors.red.shade700,
|
||||||
@@ -880,50 +1177,45 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Row(
|
GestureDetector(
|
||||||
mainAxisSize: MainAxisSize.min,
|
onTap: () =>
|
||||||
children: [
|
_showDeviceInfoSheet(context, deviceInfo),
|
||||||
Icon(
|
child: Row(
|
||||||
isTcpConnected
|
mainAxisSize: MainAxisSize.min,
|
||||||
? Icons.wifi_rounded
|
children: [
|
||||||
: Icons.bluetooth_connected_rounded,
|
|
||||||
size: 13,
|
|
||||||
color: signalColor,
|
|
||||||
),
|
|
||||||
if (!isTcpConnected &&
|
|
||||||
deviceInfo.signalRssi != null) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
SizedBox(
|
|
||||||
width: 28,
|
|
||||||
child: Text(
|
|
||||||
'${deviceInfo.signalRssi}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: signalColor,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (deviceInfo.batteryPercent != null) ...[
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
Icon(
|
||||||
BatteryDisplayHelper.getBatteryIcon(
|
isTcpConnected
|
||||||
deviceInfo.batteryPercent!,
|
? Icons.wifi_rounded
|
||||||
),
|
: Icons.bluetooth_connected_rounded,
|
||||||
size: 13,
|
size: 13,
|
||||||
color:
|
color: signalColor,
|
||||||
BatteryDisplayHelper.getBatteryColor(
|
|
||||||
deviceInfo.batteryPercent!,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
if (!isTcpConnected &&
|
||||||
SizedBox(
|
deviceInfo.signalRssi != null) ...[
|
||||||
width: 30,
|
const SizedBox(width: 4),
|
||||||
child: Text(
|
_buildMiniSignalBars(
|
||||||
|
activeBars:
|
||||||
|
BatteryDisplayHelper.getSignalBars(
|
||||||
|
deviceInfo.signalRssi!,
|
||||||
|
),
|
||||||
|
color: signalColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (deviceInfo.batteryPercent != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(
|
||||||
|
BatteryDisplayHelper.getBatteryIcon(
|
||||||
|
deviceInfo.batteryPercent!,
|
||||||
|
),
|
||||||
|
size: 13,
|
||||||
|
color:
|
||||||
|
BatteryDisplayHelper.getBatteryColor(
|
||||||
|
deviceInfo.batteryPercent!,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Text(
|
||||||
'${deviceInfo.batteryPercent!.round()}%',
|
'${deviceInfo.batteryPercent!.round()}%',
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -933,9 +1225,9 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1022,6 +1314,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
SizedBox(width: isTight ? 8 : 12),
|
SizedBox(width: isTight ? 8 : 12),
|
||||||
if (_showRxTxIndicators)
|
if (_showRxTxIndicators)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
|
onTap: () => _openLiveTraffic(provider),
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
context,
|
context,
|
||||||
@@ -1031,44 +1324,13 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: isTight
|
child: _buildCompactActivityIndicator(
|
||||||
? _buildCompactActivityIndicator(
|
rxActive: provider.rxActivity,
|
||||||
rxActive: provider.rxActivity,
|
txActive: provider.txActivity,
|
||||||
txActive: provider.txActivity,
|
),
|
||||||
)
|
|
||||||
: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.surfaceContainerHigh
|
|
||||||
.withValues(alpha: 0.85),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
_buildActivityBadge(
|
|
||||||
label: 'RX',
|
|
||||||
count: provider.rxPacketCount,
|
|
||||||
isActive: provider.rxActivity,
|
|
||||||
activeColor: Colors.green,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_buildActivityBadge(
|
|
||||||
label: 'TX',
|
|
||||||
count: provider.txPacketCount,
|
|
||||||
isActive: provider.txActivity,
|
|
||||||
activeColor: Colors.blue,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
SizedBox(width: isTight ? 24 : 74),
|
const SizedBox(width: 24),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
1154
lib/screens/live_traffic_screen.dart
Normal file
1154
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
@@ -8,6 +8,7 @@ import 'package:meshcore_client/meshcore_client.dart';
|
|||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../providers/connection_provider.dart';
|
import '../providers/connection_provider.dart';
|
||||||
import '../providers/contacts_provider.dart';
|
import '../providers/contacts_provider.dart';
|
||||||
|
import '../services/mesh_map_nodes_service.dart';
|
||||||
import '../services/route_hash_preferences.dart';
|
import '../services/route_hash_preferences.dart';
|
||||||
import '../utils/log_rx_route_decoder.dart';
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
@@ -60,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;
|
||||||
}
|
}
|
||||||
@@ -107,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;
|
||||||
}
|
}
|
||||||
@@ -154,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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -183,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)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -204,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,
|
||||||
@@ -315,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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -410,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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -518,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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -564,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(),
|
||||||
@@ -572,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!),
|
||||||
@@ -746,49 +747,82 @@ class _DecodedRouteSection extends StatelessWidget {
|
|||||||
connectionProvider.deviceInfo.selfName ??
|
connectionProvider.deviceInfo.selfName ??
|
||||||
connectionProvider.deviceInfo.displayName;
|
connectionProvider.deviceInfo.displayName;
|
||||||
|
|
||||||
return FutureBuilder<int>(
|
return FutureBuilder<List<dynamic>>(
|
||||||
future: RouteHashPreferences.getHashSize(),
|
future: Future.wait<dynamic>([
|
||||||
|
RouteHashPreferences.getHashSize(),
|
||||||
|
MeshMapNodesService.loadCachedNodes(
|
||||||
|
cacheTtl: MeshMapNodesService.traceCacheTtl,
|
||||||
|
),
|
||||||
|
]),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final decodedRoute = LogRxRouteDecoder.decode(
|
final decodedRoute = LogRxRouteDecoder.decode(
|
||||||
log.rawData,
|
log.rawData,
|
||||||
preferredHashSize: snapshot.data,
|
preferredHashSize: snapshot.data?.first as int?,
|
||||||
);
|
);
|
||||||
if (decodedRoute == null) {
|
if (decodedRoute == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final cachedNodes = snapshot.hasData
|
||||||
|
? snapshot.data![1] as List<MeshMapNode>
|
||||||
|
: const <MeshMapNode>[];
|
||||||
final resolvedPath = decodedRoute.hopHashes
|
final resolvedPath = decodedRoute.hopHashes
|
||||||
.map(
|
.map(
|
||||||
(hashHex) => LogRxRouteDecoder.resolveHash(
|
(hashHex) => _resolveHashWithFallback(
|
||||||
hashHex,
|
hashHex,
|
||||||
contacts: contacts,
|
contacts: contacts,
|
||||||
|
cachedNodes: cachedNodes,
|
||||||
ownPublicKey: ownPublicKey,
|
ownPublicKey: ownPublicKey,
|
||||||
ownName: ownName,
|
ownName: ownName,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
final originalSender = resolvedPath.isEmpty ? null : resolvedPath.first;
|
return _RouteSection(route: decodedRoute, path: resolvedPath);
|
||||||
|
|
||||||
return _RouteSection(
|
|
||||||
route: decodedRoute,
|
|
||||||
path: resolvedPath,
|
|
||||||
originalSender: originalSender,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ResolvedNodeHash _resolveHashWithFallback(
|
||||||
|
String hashHex, {
|
||||||
|
required List<Contact> contacts,
|
||||||
|
required List<MeshMapNode> cachedNodes,
|
||||||
|
required Uint8List? ownPublicKey,
|
||||||
|
required String? ownName,
|
||||||
|
}) {
|
||||||
|
final localResolved = LogRxRouteDecoder.resolveHash(
|
||||||
|
hashHex,
|
||||||
|
contacts: contacts,
|
||||||
|
ownPublicKey: ownPublicKey,
|
||||||
|
ownName: ownName,
|
||||||
|
);
|
||||||
|
if (localResolved.matchCount > 0 || localResolved.isOwnNode) {
|
||||||
|
return localResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
final normalizedHashHex = hashHex.toLowerCase();
|
||||||
|
final matches = cachedNodes
|
||||||
|
.where((node) => node.publicKey.startsWith(normalizedHashHex))
|
||||||
|
.toList();
|
||||||
|
if (matches.isEmpty) {
|
||||||
|
return localResolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
matches.sort((a, b) => b.updatedAtMs.compareTo(a.updatedAtMs));
|
||||||
|
return ResolvedNodeHash(
|
||||||
|
hashHex: normalizedHashHex,
|
||||||
|
label: matches.first.name,
|
||||||
|
isOwnNode: false,
|
||||||
|
isUniqueMatch: matches.length == 1,
|
||||||
|
matchCount: matches.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RouteSection extends StatelessWidget {
|
class _RouteSection extends StatelessWidget {
|
||||||
final DecodedLogRxRoute route;
|
final DecodedLogRxRoute route;
|
||||||
final List<ResolvedNodeHash> path;
|
final List<ResolvedNodeHash> path;
|
||||||
final ResolvedNodeHash? originalSender;
|
|
||||||
|
|
||||||
const _RouteSection({
|
const _RouteSection({required this.route, required this.path});
|
||||||
required this.route,
|
|
||||||
required this.path,
|
|
||||||
required this.originalSender,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -820,26 +854,20 @@ class _RouteSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_FactCard(
|
_FactCard(
|
||||||
icon: Icons.route,
|
icon: Icons.route,
|
||||||
label: 'Payload',
|
label: AppLocalizations.of(context)!.payload,
|
||||||
value: _payloadTypeLabel(route.payloadType),
|
value: _payloadTypeLabel(route.payloadType),
|
||||||
),
|
),
|
||||||
_FactCard(
|
_FactCard(
|
||||||
icon: Icons.hub,
|
icon: Icons.hub,
|
||||||
label: 'Hops',
|
label: AppLocalizations.of(context)!.hops,
|
||||||
value: '${route.hopCount}',
|
value: '${route.hopCount}',
|
||||||
),
|
),
|
||||||
_FactCard(
|
_FactCard(
|
||||||
icon: Icons.tag,
|
icon: Icons.tag,
|
||||||
label: 'Hash size',
|
label: AppLocalizations.of(context)!.hashSize,
|
||||||
value:
|
value:
|
||||||
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
|
'${route.hashSize} byte${route.hashSize == 1 ? '' : 's'}',
|
||||||
),
|
),
|
||||||
if (originalSender != null)
|
|
||||||
_FactCard(
|
|
||||||
icon: Icons.person_pin_circle,
|
|
||||||
label: 'Original sender',
|
|
||||||
value: _nodeLabel(originalSender!),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|||||||
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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,601 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:meshcore_client/meshcore_client.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
import '../models/device_info.dart';
|
|
||||||
import '../providers/connection_provider.dart';
|
|
||||||
import '../widgets/device/spectrum_scan_panel.dart';
|
|
||||||
|
|
||||||
class SpectrumScanScreen extends StatefulWidget {
|
|
||||||
const SpectrumScanScreen({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<SpectrumScanScreen> createState() => _SpectrumScanScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SpectrumScanScreenState extends State<SpectrumScanScreen> {
|
|
||||||
static const List<String> _bandwidthOptions = [
|
|
||||||
'7.8 kHz',
|
|
||||||
'10.4 kHz',
|
|
||||||
'15.6 kHz',
|
|
||||||
'20.8 kHz',
|
|
||||||
'31.25 kHz',
|
|
||||||
'41.7 kHz',
|
|
||||||
'62.5 kHz',
|
|
||||||
'125 kHz',
|
|
||||||
'250 kHz',
|
|
||||||
'500 kHz',
|
|
||||||
];
|
|
||||||
|
|
||||||
String _selectedBandwidth = '62.5 kHz';
|
|
||||||
bool _isSpectrumScanRunning = false;
|
|
||||||
bool _rangeInitialized = false;
|
|
||||||
String? _lastRangeSourceKey;
|
|
||||||
late double _scanRangeMinMhz;
|
|
||||||
late double _scanRangeMaxMhz;
|
|
||||||
late RangeValues _scanRangeValues;
|
|
||||||
List<SpectrumScanCandidate> _scanCandidates = const [];
|
|
||||||
int? _selectedScanFrequencyKhz;
|
|
||||||
int _completedScanSectors = 0;
|
|
||||||
int _totalScanSectors = 0;
|
|
||||||
|
|
||||||
List<SpectrumScanCandidate> get _recommendedScanCandidates =>
|
|
||||||
_scanCandidates.take(8).toList();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
final deviceInfo = _deviceInfo;
|
|
||||||
if (deviceInfo.radioBw != null &&
|
|
||||||
deviceInfo.radioBw! >= 0 &&
|
|
||||||
deviceInfo.radioBw! < _bandwidthOptions.length) {
|
|
||||||
_selectedBandwidth = _bandwidthOptions[deviceInfo.radioBw!];
|
|
||||||
}
|
|
||||||
_syncRangeFromDevice(deviceInfo);
|
|
||||||
_selectedScanFrequencyKhz = deviceInfo.radioFreq;
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceInfo get _deviceInfo => context.read<ConnectionProvider>().deviceInfo;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
_syncRangeFromDevice(context.watch<ConnectionProvider>().deviceInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _syncRangeFromDevice(DeviceInfo deviceInfo) {
|
|
||||||
final minKhz = deviceInfo.spectrumScanMinKhz;
|
|
||||||
final maxKhz = deviceInfo.spectrumScanMaxKhz;
|
|
||||||
late double normalizedMinMhz;
|
|
||||||
late double normalizedMaxMhz;
|
|
||||||
late String sourceKey;
|
|
||||||
|
|
||||||
if (minKhz != null && maxKhz != null && maxKhz > minKhz) {
|
|
||||||
final normalized = _normalizeScanRange(
|
|
||||||
minKhz / 1000.0,
|
|
||||||
maxKhz / 1000.0,
|
|
||||||
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
|
|
||||||
);
|
|
||||||
normalizedMinMhz = normalized.$1;
|
|
||||||
normalizedMaxMhz = normalized.$2;
|
|
||||||
sourceKey = 'fw:$minKhz:$maxKhz';
|
|
||||||
} else {
|
|
||||||
final normalized = _normalizeScanRange(
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
deviceInfo.radioFreq != null ? deviceInfo.radioFreq! / 1000.0 : null,
|
|
||||||
);
|
|
||||||
normalizedMinMhz = normalized.$1;
|
|
||||||
normalizedMaxMhz = normalized.$2;
|
|
||||||
sourceKey =
|
|
||||||
'fallback:${deviceInfo.radioFreq != null ? deviceInfo.radioFreq! ~/ 1000 : 869525}';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_rangeInitialized && _lastRangeSourceKey == sourceKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_scanRangeMinMhz = normalizedMinMhz;
|
|
||||||
_scanRangeMaxMhz = normalizedMaxMhz;
|
|
||||||
|
|
||||||
if (_scanRangeMaxMhz <= _scanRangeMinMhz) {
|
|
||||||
_scanRangeMaxMhz = _scanRangeMinMhz + 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
_scanRangeValues = RangeValues(_scanRangeMinMhz, _scanRangeMaxMhz);
|
|
||||||
_rangeInitialized = true;
|
|
||||||
_lastRangeSourceKey = sourceKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
(double, double) _normalizeScanRange(
|
|
||||||
double? minMhz,
|
|
||||||
double? maxMhz,
|
|
||||||
double? centerMhz,
|
|
||||||
) {
|
|
||||||
const hardMinMhz = 800.0;
|
|
||||||
const hardMaxMhz = 950.0;
|
|
||||||
final center = centerMhz ?? 869.525;
|
|
||||||
|
|
||||||
if (minMhz != null && maxMhz != null) {
|
|
||||||
final clampedMin = minMhz.clamp(hardMinMhz, hardMaxMhz);
|
|
||||||
final clampedMax = maxMhz.clamp(hardMinMhz, hardMaxMhz);
|
|
||||||
if (clampedMax > clampedMin) {
|
|
||||||
return (clampedMin, clampedMax);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (center >= 900.0 && center <= 930.0) {
|
|
||||||
return (902.0, 928.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (863.0, 870.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
double _bandwidthToKhz(String bw) {
|
|
||||||
switch (bw) {
|
|
||||||
case '7.8 kHz':
|
|
||||||
return 7.8;
|
|
||||||
case '10.4 kHz':
|
|
||||||
return 10.4;
|
|
||||||
case '15.6 kHz':
|
|
||||||
return 15.6;
|
|
||||||
case '20.8 kHz':
|
|
||||||
return 20.8;
|
|
||||||
case '31.25 kHz':
|
|
||||||
return 31.25;
|
|
||||||
case '41.7 kHz':
|
|
||||||
return 41.7;
|
|
||||||
case '62.5 kHz':
|
|
||||||
return 62.5;
|
|
||||||
case '125 kHz':
|
|
||||||
return 125.0;
|
|
||||||
case '250 kHz':
|
|
||||||
return 250.0;
|
|
||||||
case '500 kHz':
|
|
||||||
return 500.0;
|
|
||||||
default:
|
|
||||||
return 62.5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _currentParamProfile(DeviceInfo deviceInfo) {
|
|
||||||
final sf = deviceInfo.radioSf ?? 8;
|
|
||||||
final cr = deviceInfo.radioCr ?? 8;
|
|
||||||
return 'BW $_selectedBandwidth | SF$sf | CR 4/$cr';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _recommendationTitle(int index) {
|
|
||||||
switch (index) {
|
|
||||||
case 0:
|
|
||||||
return 'Best candidate';
|
|
||||||
case 1:
|
|
||||||
return 'Alternate';
|
|
||||||
case 2:
|
|
||||||
return 'Fallback';
|
|
||||||
default:
|
|
||||||
return 'Candidate ${index + 1}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<int> _possibleBracketFrequenciesKhz() {
|
|
||||||
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
|
|
||||||
final halfBandwidthKhz = bandwidthKhz / 2.0;
|
|
||||||
final startKhz = (_scanRangeValues.start * 1000).round();
|
|
||||||
final stopKhz = (_scanRangeValues.end * 1000).round();
|
|
||||||
final firstCenterKhz = (startKhz + halfBandwidthKhz).round();
|
|
||||||
final lastCenterKhz = (stopKhz - halfBandwidthKhz).round();
|
|
||||||
|
|
||||||
if (lastCenterKhz < firstCenterKhz) {
|
|
||||||
return const [];
|
|
||||||
}
|
|
||||||
|
|
||||||
final stepKhz = bandwidthKhz >= 125.0 ? bandwidthKhz.round() : 25;
|
|
||||||
final centers = <int>[];
|
|
||||||
for (
|
|
||||||
var centerKhz = firstCenterKhz;
|
|
||||||
centerKhz <= lastCenterKhz && centers.length < 8;
|
|
||||||
centerKhz += stepKhz
|
|
||||||
) {
|
|
||||||
centers.add(centerKhz);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (centers.isEmpty || centers.last != lastCenterKhz) {
|
|
||||||
centers.add(lastCenterKhz);
|
|
||||||
}
|
|
||||||
|
|
||||||
return centers.toSet().toList()..sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _resetDerivedScanResults() {
|
|
||||||
_scanCandidates = const [];
|
|
||||||
_selectedScanFrequencyKhz = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<(int startKhz, int stopKhz)> _buildScanSectors(double bandwidthKhz) {
|
|
||||||
final startKhz = (_scanRangeValues.start * 1000).round();
|
|
||||||
final stopKhz = (_scanRangeValues.end * 1000).round();
|
|
||||||
final sectorWidthKhz = (bandwidthKhz * 24).round().clamp(250, 1200);
|
|
||||||
final overlapKhz = bandwidthKhz.round().clamp(8, 500);
|
|
||||||
final sectors = <(int startKhz, int stopKhz)>[];
|
|
||||||
|
|
||||||
var sectorStartKhz = startKhz;
|
|
||||||
while (sectorStartKhz < stopKhz) {
|
|
||||||
final sectorStopKhz = (sectorStartKhz + sectorWidthKhz).clamp(
|
|
||||||
sectorStartKhz + overlapKhz,
|
|
||||||
stopKhz,
|
|
||||||
);
|
|
||||||
sectors.add((sectorStartKhz, sectorStopKhz));
|
|
||||||
if (sectorStopKhz >= stopKhz) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
sectorStartKhz = sectorStopKhz - overlapKhz;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sectors;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<SpectrumScanCandidate> _mergeSectorCandidates(
|
|
||||||
Iterable<SpectrumScanCandidate> candidates,
|
|
||||||
) {
|
|
||||||
final byFrequency = <int, SpectrumScanCandidate>{};
|
|
||||||
for (final candidate in candidates) {
|
|
||||||
final existing = byFrequency[candidate.centerFrequencyKhz];
|
|
||||||
if (existing == null ||
|
|
||||||
candidate.occupancyPercent < existing.occupancyPercent ||
|
|
||||||
(candidate.occupancyPercent == existing.occupancyPercent &&
|
|
||||||
candidate.peakRssiDbm < existing.peakRssiDbm) ||
|
|
||||||
(candidate.occupancyPercent == existing.occupancyPercent &&
|
|
||||||
candidate.peakRssiDbm == existing.peakRssiDbm &&
|
|
||||||
candidate.avgRssiDbm < existing.avgRssiDbm)) {
|
|
||||||
byFrequency[candidate.centerFrequencyKhz] = candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final merged = byFrequency.values.toList()
|
|
||||||
..sort((a, b) {
|
|
||||||
final occupancyCompare = a.occupancyPercent.compareTo(
|
|
||||||
b.occupancyPercent,
|
|
||||||
);
|
|
||||||
if (occupancyCompare != 0) return occupancyCompare;
|
|
||||||
final peakCompare = a.peakRssiDbm.compareTo(b.peakRssiDbm);
|
|
||||||
if (peakCompare != 0) return peakCompare;
|
|
||||||
return a.avgRssiDbm.compareTo(b.avgRssiDbm);
|
|
||||||
});
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _runSpectrumScan() async {
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
|
||||||
final bandwidthKhz = _bandwidthToKhz(_selectedBandwidth);
|
|
||||||
final sectors = _buildScanSectors(bandwidthKhz);
|
|
||||||
final sectorCandidates = <SpectrumScanCandidate>[];
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isSpectrumScanRunning = true;
|
|
||||||
_resetDerivedScanResults();
|
|
||||||
_completedScanSectors = 0;
|
|
||||||
_totalScanSectors = sectors.length;
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (var i = 0; i < sectors.length; i++) {
|
|
||||||
final sector = sectors[i];
|
|
||||||
final result = await connectionProvider.scanSpectrum(
|
|
||||||
startFrequencyKhz: sector.$1,
|
|
||||||
stopFrequencyKhz: sector.$2,
|
|
||||||
bandwidthKhz: bandwidthKhz.round(),
|
|
||||||
stepKhz: (bandwidthKhz / 2).round().clamp(1, 1000),
|
|
||||||
dwellMs: 160,
|
|
||||||
thresholdDb: 8,
|
|
||||||
);
|
|
||||||
if (result != null) {
|
|
||||||
sectorCandidates.addAll(result.candidates);
|
|
||||||
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_scanCandidates = mergedCandidates;
|
|
||||||
if (_selectedScanFrequencyKhz == null &&
|
|
||||||
mergedCandidates.isNotEmpty) {
|
|
||||||
_selectedScanFrequencyKhz =
|
|
||||||
mergedCandidates.first.centerFrequencyKhz;
|
|
||||||
}
|
|
||||||
_completedScanSectors = i + 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_completedScanSectors = i + 1;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isSpectrumScanRunning = false;
|
|
||||||
if (_completedScanSectors == 0) {
|
|
||||||
_totalScanSectors = sectors.length;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
final mergedCandidates = _mergeSectorCandidates(sectorCandidates);
|
|
||||||
if (mergedCandidates.isEmpty) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Spectrum scan returned no candidate frequencies'),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_scanCandidates = mergedCandidates;
|
|
||||||
_selectedScanFrequencyKhz = mergedCandidates.first.centerFrequencyKhz;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _applySelectedScanFrequency() async {
|
|
||||||
if (_selectedScanFrequencyKhz == null) return;
|
|
||||||
|
|
||||||
final connectionProvider = context.read<ConnectionProvider>();
|
|
||||||
final deviceInfo = connectionProvider.deviceInfo;
|
|
||||||
|
|
||||||
await connectionProvider.setRadioParams(
|
|
||||||
frequency: _selectedScanFrequencyKhz!,
|
|
||||||
bandwidth: _bandwidthOptions.indexOf(_selectedBandwidth),
|
|
||||||
spreadingFactor: deviceInfo.radioSf ?? 8,
|
|
||||||
codingRate: deviceInfo.radioCr ?? 8,
|
|
||||||
repeat: deviceInfo.clientRepeat,
|
|
||||||
);
|
|
||||||
await connectionProvider.refreshDeviceInfo();
|
|
||||||
|
|
||||||
if (!mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Applied ${(_selectedScanFrequencyKhz! / 1000.0).toStringAsFixed(3)} MHz',
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
int? _currentPreviewFrequencyKhz(DeviceInfo deviceInfo) {
|
|
||||||
if (_selectedScanFrequencyKhz != null) {
|
|
||||||
return _selectedScanFrequencyKhz;
|
|
||||||
}
|
|
||||||
return deviceInfo.radioFreq;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final deviceInfo = context.watch<ConnectionProvider>().deviceInfo;
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final possibleBracketFrequencies = _possibleBracketFrequenciesKhz();
|
|
||||||
final recommendedScanCandidates = _recommendedScanCandidates;
|
|
||||||
final recommendationFrequencies = recommendedScanCandidates.isNotEmpty
|
|
||||||
? recommendedScanCandidates
|
|
||||||
.map((candidate) => candidate.centerFrequencyKhz)
|
|
||||||
.toList()
|
|
||||||
: possibleBracketFrequencies;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('Spectrum Scan')),
|
|
||||||
body: ListView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
|
||||||
Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
DropdownButtonFormField<String>(
|
|
||||||
initialValue: _selectedBandwidth,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Bandwidth',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
helperText:
|
|
||||||
'Scan and apply frequencies for this bandwidth',
|
|
||||||
),
|
|
||||||
items: _bandwidthOptions.map((value) {
|
|
||||||
return DropdownMenuItem<String>(
|
|
||||||
value: value,
|
|
||||||
child: Text(value),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value == null) return;
|
|
||||||
setState(() {
|
|
||||||
_selectedBandwidth = value;
|
|
||||||
_resetDerivedScanResults();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
deviceInfo.spectrumScanMinKhz != null &&
|
|
||||||
deviceInfo.spectrumScanMaxKhz != null
|
|
||||||
? 'Firmware scan range: ${_scanRangeMinMhz.toStringAsFixed(3)}-${_scanRangeMaxMhz.toStringAsFixed(3)} MHz'
|
|
||||||
: 'Fallback scan range selected from current band. MeshCore commonly uses EU868 or US915.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_isSpectrumScanRunning) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Scanning sector ${_completedScanSectors + 1} of $_totalScanSectors. Results update as each sector completes.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
SpectrumScanPanel(
|
|
||||||
theme: theme,
|
|
||||||
scanSupported: deviceInfo.supportsSpectrumScan == true,
|
|
||||||
isRunning: _isSpectrumScanRunning,
|
|
||||||
rangeMinMhz: _scanRangeMinMhz,
|
|
||||||
rangeMaxMhz: _scanRangeMaxMhz,
|
|
||||||
rangeValues: _scanRangeValues,
|
|
||||||
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
|
|
||||||
selectedFrequencyKhz: _currentPreviewFrequencyKhz(
|
|
||||||
deviceInfo,
|
|
||||||
),
|
|
||||||
graphCandidates: _scanCandidates,
|
|
||||||
selectableCandidates: recommendedScanCandidates,
|
|
||||||
onRangeChanged: (values) {
|
|
||||||
setState(() {
|
|
||||||
_scanRangeValues = values;
|
|
||||||
_resetDerivedScanResults();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onCandidateChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_selectedScanFrequencyKhz = value;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onRunScan: _runSpectrumScan,
|
|
||||||
onApplySelected: _applySelectedScanFrequency,
|
|
||||||
),
|
|
||||||
if (recommendationFrequencies.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
Text(
|
|
||||||
_scanCandidates.isNotEmpty
|
|
||||||
? 'Recommended profiles'
|
|
||||||
: 'Possible brackets',
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_scanCandidates.isNotEmpty
|
|
||||||
? 'Suggested frequencies from the latest scan with the radio parameters to keep alongside them.'
|
|
||||||
: 'Usable frequency brackets derived from the selected span and bandwidth, even without live scan data.',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
for (
|
|
||||||
var i = 0;
|
|
||||||
i < recommendationFrequencies.length;
|
|
||||||
i++
|
|
||||||
) ...[
|
|
||||||
_RecommendationTile(
|
|
||||||
title: _recommendationTitle(i),
|
|
||||||
frequencyKhz: recommendationFrequencies[i],
|
|
||||||
candidate: recommendedScanCandidates.isNotEmpty
|
|
||||||
? recommendedScanCandidates[i]
|
|
||||||
: null,
|
|
||||||
bandwidthKhz: _bandwidthToKhz(_selectedBandwidth),
|
|
||||||
paramsLabel: _currentParamProfile(deviceInfo),
|
|
||||||
isSelected:
|
|
||||||
_selectedScanFrequencyKhz ==
|
|
||||||
recommendationFrequencies[i],
|
|
||||||
onSelect: () {
|
|
||||||
setState(() {
|
|
||||||
_selectedScanFrequencyKhz =
|
|
||||||
recommendationFrequencies[i];
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
if (i != recommendationFrequencies.length - 1)
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RecommendationTile extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final int frequencyKhz;
|
|
||||||
final SpectrumScanCandidate? candidate;
|
|
||||||
final double bandwidthKhz;
|
|
||||||
final String paramsLabel;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback onSelect;
|
|
||||||
|
|
||||||
const _RecommendationTile({
|
|
||||||
required this.title,
|
|
||||||
required this.frequencyKhz,
|
|
||||||
required this.candidate,
|
|
||||||
required this.bandwidthKhz,
|
|
||||||
required this.paramsLabel,
|
|
||||||
required this.isSelected,
|
|
||||||
required this.onSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final scheme = theme.colorScheme;
|
|
||||||
|
|
||||||
return InkWell(
|
|
||||||
onTap: onSelect,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? scheme.primaryContainer.withValues(alpha: 0.55)
|
|
||||||
: scheme.surfaceContainerHighest.withValues(alpha: 0.35),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected ? scheme.primary : scheme.outlineVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isSelected)
|
|
||||||
Icon(Icons.check_circle, color: scheme.primary, size: 18),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'${(frequencyKhz / 1000.0).toStringAsFixed(3)} MHz',
|
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(paramsLabel, style: theme.textTheme.bodyMedium),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
candidate != null
|
|
||||||
? 'Occupancy ${candidate!.occupancyPercent}% | Avg ${candidate!.avgRssiDbm} dBm | Peak ${candidate!.peakRssiDbm} dBm'
|
|
||||||
: 'Bracket ${(frequencyKhz / 1000.0 - bandwidthKhz / 2000.0).toStringAsFixed(3)}-${(frequencyKhz / 1000.0 + bandwidthKhz / 2000.0).toStringAsFixed(3)} MHz',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: scheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
static const int _totalPages = 5;
|
bool _isApplyingDeviceSetup = false;
|
||||||
|
static const int _totalPages = 6;
|
||||||
|
static const List<_RadioPreset> _radioPresets = [
|
||||||
|
// Official MeshCore config presets from https://api.meshcore.nz/api/v1/config
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia',
|
||||||
|
label: 'Australia',
|
||||||
|
summary: '915.800 MHz, BW 250 kHz, SF10, CR5',
|
||||||
|
frequencyKhz: 915800,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 10,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia_narrow',
|
||||||
|
label: 'Australia (Narrow)',
|
||||||
|
summary: '916.575 MHz, BW 62.5 kHz, SF7, CR8',
|
||||||
|
frequencyKhz: 916575,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia_sa_wa',
|
||||||
|
label: 'Australia: SA, WA',
|
||||||
|
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR8',
|
||||||
|
frequencyKhz: 923125,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'australia_qld',
|
||||||
|
label: 'Australia: QLD',
|
||||||
|
summary: '923.125 MHz, BW 62.5 kHz, SF8, CR5',
|
||||||
|
frequencyKhz: 923125,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'eu_uk_narrow',
|
||||||
|
label: 'EU/UK (Narrow)',
|
||||||
|
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
|
||||||
|
frequencyKhz: 869618,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'eu_uk_deprecated',
|
||||||
|
label: 'EU/UK (Deprecated)',
|
||||||
|
summary: '869.525 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 869525,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'czech_republic_narrow',
|
||||||
|
label: 'Czech Republic (Narrow)',
|
||||||
|
summary: '869.432 MHz, BW 62.5 kHz, SF7, CR5',
|
||||||
|
frequencyKhz: 869432,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'eu_433_long_range',
|
||||||
|
label: 'EU 433MHz (Long Range)',
|
||||||
|
summary: '433.650 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 433650,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'new_zealand',
|
||||||
|
label: 'New Zealand',
|
||||||
|
summary: '917.375 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 917375,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'new_zealand_narrow',
|
||||||
|
label: 'New Zealand (Narrow)',
|
||||||
|
summary: '917.375 MHz, BW 62.5 kHz, SF7, CR5',
|
||||||
|
frequencyKhz: 917375,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'portugal_433',
|
||||||
|
label: 'Portugal 433',
|
||||||
|
summary: '433.375 MHz, BW 62.5 kHz, SF9, CR6',
|
||||||
|
frequencyKhz: 433375,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 9,
|
||||||
|
codingRate: 6,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'portugal_868',
|
||||||
|
label: 'Portugal 868',
|
||||||
|
summary: '869.618 MHz, BW 62.5 kHz, SF7, CR6',
|
||||||
|
frequencyKhz: 869618,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 6,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'switzerland',
|
||||||
|
label: 'Switzerland',
|
||||||
|
summary: '869.618 MHz, BW 62.5 kHz, SF8, CR8',
|
||||||
|
frequencyKhz: 869618,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 8,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'usa_canada_recommended',
|
||||||
|
label: 'USA/Canada (Recommended)',
|
||||||
|
summary: '910.525 MHz, BW 62.5 kHz, SF7, CR5',
|
||||||
|
frequencyKhz: 910525,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 7,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'vietnam_narrow',
|
||||||
|
label: 'Vietnam (Narrow)',
|
||||||
|
summary: '920.250 MHz, BW 62.5 kHz, SF8, CR5',
|
||||||
|
frequencyKhz: 920250,
|
||||||
|
bandwidth: 6,
|
||||||
|
spreadingFactor: 8,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
_RadioPreset(
|
||||||
|
id: 'vietnam_deprecated',
|
||||||
|
label: 'Vietnam (Deprecated)',
|
||||||
|
summary: '920.250 MHz, BW 250 kHz, SF11, CR5',
|
||||||
|
frequencyKhz: 920250,
|
||||||
|
bandwidth: 8,
|
||||||
|
spreadingFactor: 11,
|
||||||
|
codingRate: 5,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
_RadioPreset _selectedPreset = _radioPresets[4];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final deviceInfo = context.read<ConnectionProvider>().deviceInfo;
|
||||||
|
_deviceNameController.text =
|
||||||
|
deviceInfo.selfName ?? deviceInfo.deviceName ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@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,8 +351,10 @@ 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),
|
||||||
_buildChannelPage(context, l10n, colorScheme),
|
_buildChannelPage(context, l10n, colorScheme),
|
||||||
_buildContactsPage(context, l10n, colorScheme),
|
_buildContactsPage(context, l10n, colorScheme),
|
||||||
@@ -108,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(
|
||||||
@@ -130,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(
|
||||||
@@ -142,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),
|
||||||
@@ -165,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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -176,33 +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(icon: Icons.radio, text: l10n.wizardConnectingFeature1),
|
_FeatureItem(icon: Icons.chat, text: l10n.wizardMessagingFeature1),
|
||||||
_FeatureItem(icon: Icons.link, text: l10n.wizardConnectingFeature2),
|
_FeatureItem(icon: Icons.campaign, text: l10n.wizardMessagingFeature2),
|
||||||
_FeatureItem(icon: Icons.wifi_off, text: l10n.wizardConnectingFeature3),
|
_FeatureItem(icon: Icons.mic, text: l10n.wizardMessagingFeature3),
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildConnectDevicePage(
|
||||||
|
BuildContext context,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
) {
|
||||||
|
final connectionProvider = context.watch<ConnectionProvider>();
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
final isConnected = deviceInfo.isConnected;
|
||||||
|
final connectedName = deviceInfo.selfName ?? deviceInfo.deviceName;
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) => Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: 600,
|
||||||
|
maxHeight: constraints.maxHeight,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildPageHeader(
|
||||||
|
context: context,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
icon: Icons.bluetooth_connected,
|
||||||
|
iconColor: Colors.green,
|
||||||
|
title: l10n.wizardConnectDeviceTitle,
|
||||||
|
description: l10n.wizardConnectDeviceDescription,
|
||||||
|
badge: l10n.wizardSetupBadge,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildContentCard(
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
isConnected
|
||||||
|
? Icons.check_circle
|
||||||
|
: Icons.bluetooth_searching,
|
||||||
|
color: isConnected
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(icon: Icons.public, text: l10n.wizardChannelFeature1),
|
_FeatureItem(
|
||||||
_FeatureItem(icon: Icons.groups, text: l10n.wizardChannelFeature2),
|
icon: Icons.person_add_alt_1,
|
||||||
_FeatureItem(icon: Icons.send, text: l10n.wizardChannelFeature3),
|
text: l10n.wizardNetworkFeature1,
|
||||||
|
),
|
||||||
|
_FeatureItem(icon: Icons.route, text: l10n.wizardNetworkFeature2),
|
||||||
|
_FeatureItem(icon: Icons.hub, text: l10n.wizardNetworkFeature3),
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -214,17 +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(icon: Icons.person_add, text: l10n.wizardContactsFeature1),
|
_FeatureItem(icon: Icons.location_on, text: l10n.wizardMapOpsFeature1),
|
||||||
_FeatureItem(icon: Icons.chat, text: l10n.wizardContactsFeature2),
|
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapOpsFeature2),
|
||||||
_FeatureItem(
|
_FeatureItem(icon: Icons.router, text: l10n.wizardMapOpsFeature3),
|
||||||
icon: Icons.battery_std,
|
|
||||||
text: l10n.wizardContactsFeature3,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -236,18 +698,17 @@ 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.location_on, text: l10n.wizardMapFeature1),
|
_FeatureItem(icon: Icons.settings, text: l10n.wizardToolsFeature1),
|
||||||
|
_FeatureItem(icon: Icons.sensors, text: l10n.wizardToolsFeature2),
|
||||||
_FeatureItem(
|
_FeatureItem(
|
||||||
icon: Icons.person_pin_circle,
|
icon: Icons.analytics_outlined,
|
||||||
text: l10n.wizardMapFeature2,
|
text: l10n.wizardToolsFeature3,
|
||||||
),
|
),
|
||||||
_FeatureItem(icon: Icons.offline_pin, text: l10n.wizardMapFeature3),
|
|
||||||
_FeatureItem(icon: Icons.draw, text: l10n.wizardMapFeature4),
|
|
||||||
],
|
],
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
);
|
);
|
||||||
@@ -261,69 +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,
|
||||||
textAlign: TextAlign.center,
|
icon: icon,
|
||||||
),
|
iconColor: iconColor,
|
||||||
const SizedBox(height: 16),
|
title: title,
|
||||||
// Description
|
description: description,
|
||||||
Text(
|
badge: l10n.wizardOverviewBadge,
|
||||||
description,
|
|
||||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
if (features != null && features.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
// Features list
|
|
||||||
...features.map(
|
|
||||||
(feature) => Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(feature.icon, color: colorScheme.primary, size: 24),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
feature.text,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
if (features != null && features.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_buildContentCard(
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
child: Column(
|
||||||
|
children: features
|
||||||
|
.map(
|
||||||
|
(feature) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 7),
|
||||||
|
child: _buildFeatureRow(
|
||||||
|
context,
|
||||||
|
colorScheme,
|
||||||
|
feature,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
const SizedBox(height: 20),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPageHeader({
|
||||||
|
required BuildContext context,
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
required IconData icon,
|
||||||
|
required Color iconColor,
|
||||||
|
required String title,
|
||||||
|
required String description,
|
||||||
|
required String badge,
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
badge,
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
color: colorScheme.onSecondaryContainer,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconColor.withValues(alpha: 0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 56, color: iconColor),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.72),
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContentCard({
|
||||||
|
required ColorScheme colorScheme,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFeatureRow(
|
||||||
|
BuildContext context,
|
||||||
|
ColorScheme colorScheme,
|
||||||
|
_FeatureItem feature, {
|
||||||
|
double iconSize = 22,
|
||||||
|
double gap = 14,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(feature.icon, color: colorScheme.primary, size: iconSize),
|
||||||
|
SizedBox(width: gap),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
feature.text,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FeatureItem {
|
class _FeatureItem {
|
||||||
@@ -332,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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
156
lib/services/app_config_snapshot_service.dart
Normal file
156
lib/services/app_config_snapshot_service.dart
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
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,
|
||||||
|
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
|
||||||
|
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.autoRouteRotationEnabled != null) {
|
||||||
|
await appProvider.toggleAutoRouteRotationEnabled(
|
||||||
|
section.autoRouteRotationEnabled!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
178
lib/services/bthome_met_history.dart
Normal file
178
lib/services/bthome_met_history.dart
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
enum BTHomeMetMeasurement {
|
||||||
|
temperature(1, 'Temperature', '°C'),
|
||||||
|
humidity(2, 'Humidity', '%'),
|
||||||
|
windSpeed(3, 'Wind speed', 'm/s'),
|
||||||
|
gust(4, 'Wind gust', 'm/s'),
|
||||||
|
rain(5, 'Rain', 'mm');
|
||||||
|
|
||||||
|
const BTHomeMetMeasurement(this.id, this.label, this.unit);
|
||||||
|
|
||||||
|
final int id;
|
||||||
|
final String label;
|
||||||
|
final String unit;
|
||||||
|
|
||||||
|
static BTHomeMetMeasurement? fromId(int id) {
|
||||||
|
for (final value in BTHomeMetMeasurement.values) {
|
||||||
|
if (value.id == id) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BTHomeMetHistoryPage {
|
||||||
|
const BTHomeMetHistoryPage({
|
||||||
|
required this.measurement,
|
||||||
|
required this.page,
|
||||||
|
required this.values,
|
||||||
|
});
|
||||||
|
|
||||||
|
final BTHomeMetMeasurement measurement;
|
||||||
|
final int page;
|
||||||
|
final List<double> values;
|
||||||
|
|
||||||
|
double? get latest => values.isEmpty ? null : values.last;
|
||||||
|
double? get minimum => values.isEmpty
|
||||||
|
? null
|
||||||
|
: values.reduce((left, right) => left < right ? left : right);
|
||||||
|
double? get maximum => values.isEmpty
|
||||||
|
? null
|
||||||
|
: values.reduce((left, right) => left > right ? left : right);
|
||||||
|
}
|
||||||
|
|
||||||
|
class BTHomeMetHistoryFormatException implements Exception {
|
||||||
|
const BTHomeMetHistoryFormatException(this.message);
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BTHomeMetHistoryParser {
|
||||||
|
static BTHomeMetHistoryPage parse(String text) {
|
||||||
|
final parts = text
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.toList(growable: false);
|
||||||
|
if (parts.length < 3) {
|
||||||
|
throw const BTHomeMetHistoryFormatException(
|
||||||
|
'MET history response is too short.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final measurementId = int.tryParse(parts[0]);
|
||||||
|
final page = int.tryParse(parts[1]);
|
||||||
|
final count = int.tryParse(parts[2]);
|
||||||
|
if (measurementId == null || page == null || count == null) {
|
||||||
|
throw const BTHomeMetHistoryFormatException(
|
||||||
|
'MET history header is invalid.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final measurement = BTHomeMetMeasurement.fromId(measurementId);
|
||||||
|
if (measurement == null) {
|
||||||
|
throw BTHomeMetHistoryFormatException(
|
||||||
|
'Unsupported MET history measurement id: $measurementId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (count < 0) {
|
||||||
|
throw const BTHomeMetHistoryFormatException(
|
||||||
|
'MET history sample count is invalid.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (parts.length != count + 3) {
|
||||||
|
throw BTHomeMetHistoryFormatException(
|
||||||
|
'MET history sample count mismatch: expected $count values, got ${parts.length - 3}.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final values = <double>[];
|
||||||
|
for (final part in parts.skip(3)) {
|
||||||
|
final value = double.tryParse(part);
|
||||||
|
if (value == null) {
|
||||||
|
throw BTHomeMetHistoryFormatException(
|
||||||
|
'Invalid MET history sample value: $part',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
values.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BTHomeMetHistoryPage(
|
||||||
|
measurement: measurement,
|
||||||
|
page: page,
|
||||||
|
values: List<double>.unmodifiable(values),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isTruthyCapabilityValue(Object? value) {
|
||||||
|
if (value is num) {
|
||||||
|
return value > 0;
|
||||||
|
}
|
||||||
|
if (value is bool) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _hasBTHomeMetCapability(Contact? contact) {
|
||||||
|
final extraSensorData = contact?.telemetry?.extraSensorData;
|
||||||
|
if (extraSensorData == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MET history is only enabled when the node advertises an explicit
|
||||||
|
// capability marker on channel 1. Accept a small set of channel-1 marker
|
||||||
|
// keys so the app remains tolerant while firmware-side encoding settles.
|
||||||
|
const capabilityKeys = <String>[
|
||||||
|
'met_capability',
|
||||||
|
'met_capability_1',
|
||||||
|
'generic_sensor_1',
|
||||||
|
'light_level_1',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final key in capabilityKeys) {
|
||||||
|
if (_isTruthyCapabilityValue(extraSensorData[key])) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BTHomeMetMeasurement> bTHomeMetMeasurementsForContact(Contact? contact) {
|
||||||
|
final telemetry = contact?.telemetry;
|
||||||
|
if (telemetry == null || !_hasBTHomeMetCapability(contact)) {
|
||||||
|
return const <BTHomeMetMeasurement>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final measurements = <BTHomeMetMeasurement>[
|
||||||
|
if (telemetry.temperature != null) BTHomeMetMeasurement.temperature,
|
||||||
|
if (telemetry.humidity != null) BTHomeMetMeasurement.humidity,
|
||||||
|
];
|
||||||
|
|
||||||
|
final extraSensorData = telemetry.extraSensorData;
|
||||||
|
if (extraSensorData != null) {
|
||||||
|
if (extraSensorData.keys.any(
|
||||||
|
(key) => key.startsWith('speed_') || key.startsWith('signed_speed_'),
|
||||||
|
)) {
|
||||||
|
measurements.add(BTHomeMetMeasurement.windSpeed);
|
||||||
|
}
|
||||||
|
if (extraSensorData.keys.any((key) => key.startsWith('gust_'))) {
|
||||||
|
measurements.add(BTHomeMetMeasurement.gust);
|
||||||
|
}
|
||||||
|
if (extraSensorData.keys.any((key) => key.startsWith('rain_'))) {
|
||||||
|
measurements.add(BTHomeMetMeasurement.rain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return List<BTHomeMetMeasurement>.unmodifiable(measurements);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool supportsBTHomeMetHistory(Contact? contact) =>
|
||||||
|
bTHomeMetMeasurementsForContact(contact).isNotEmpty;
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform, debugPrint, kIsWeb;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
/// Service for accessing build information from native platform code
|
/// Service for accessing build information from native platform code
|
||||||
/// Currently supports Android only - returns "unknown" for other platforms
|
/// Currently supports Android only - returns "unknown" for other platforms
|
||||||
@@ -25,7 +24,7 @@ class BuildInfoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only Android has the platform channel implementation
|
// Only Android has the platform channel implementation
|
||||||
if (!Platform.isAndroid) {
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
||||||
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
debugPrint('[BuildInfoService] Not on Android platform, returning "unknown"');
|
||||||
_cachedCommitHash = 'unknown';
|
_cachedCommitHash = 'unknown';
|
||||||
return _cachedCommitHash!;
|
return _cachedCommitHash!;
|
||||||
|
|||||||
@@ -5,6 +5,76 @@ import 'package:meshcore_client/meshcore_client.dart';
|
|||||||
/// Cayenne LPP (Low Power Payload) data parser
|
/// Cayenne LPP (Low Power Payload) data parser
|
||||||
/// Used for decoding telemetry sensor data from MeshCore devices
|
/// Used for decoding telemetry sensor data from MeshCore devices
|
||||||
class CayenneLppParser {
|
class CayenneLppParser {
|
||||||
|
static const int _selfTelemetryChannel = 1;
|
||||||
|
static const int _lppGenericSensor = 100;
|
||||||
|
static const int _lppCurrent = 117;
|
||||||
|
static const int _lppFrequency = 118;
|
||||||
|
static const int _lppPercentage = 120;
|
||||||
|
static const int _lppAltitude = 121;
|
||||||
|
static const int _lppConcentration = 125;
|
||||||
|
static const int _lppPower = 128;
|
||||||
|
static const int _lppSpeed = 129;
|
||||||
|
static const int _lppDistance = 130;
|
||||||
|
static const int _lppEnergy = 131;
|
||||||
|
static const int _lppDirection = 132;
|
||||||
|
static const int _lppUnixTime = 133;
|
||||||
|
static const int _lppColour = 135;
|
||||||
|
static const int _lppGust = 137;
|
||||||
|
static const int _lppDewPoint = 138;
|
||||||
|
static const int _lppRain = 139;
|
||||||
|
static const int _lppSwitch = 142;
|
||||||
|
static const int _lppBinaryBool = 143;
|
||||||
|
static const int _lppBinaryPowerSwitch = 144;
|
||||||
|
static const int _lppBinaryOpen = 145;
|
||||||
|
static const int _lppBinaryBatteryLow = 146;
|
||||||
|
static const int _lppBinaryCharging = 147;
|
||||||
|
static const int _lppBinaryCarbonMonoxide = 148;
|
||||||
|
static const int _lppBinaryCold = 149;
|
||||||
|
static const int _lppBinaryConnectivity = 150;
|
||||||
|
static const int _lppBinaryDoor = 151;
|
||||||
|
static const int _lppBinaryGarageDoor = 152;
|
||||||
|
static const int _lppBinaryGas = 153;
|
||||||
|
static const int _lppBinaryHeat = 154;
|
||||||
|
static const int _lppBinaryLight = 155;
|
||||||
|
static const int _lppBinaryLock = 156;
|
||||||
|
static const int _lppBinaryMoisture = 157;
|
||||||
|
static const int _lppBinaryMotion = 158;
|
||||||
|
static const int _lppBinaryMoving = 159;
|
||||||
|
static const int _lppBinaryOccupancy = 160;
|
||||||
|
static const int _lppBinaryPlug = 161;
|
||||||
|
static const int _lppBinaryPresence = 162;
|
||||||
|
static const int _lppBinaryProblem = 163;
|
||||||
|
static const int _lppBinaryRunning = 164;
|
||||||
|
static const int _lppBinarySafety = 165;
|
||||||
|
static const int _lppBinarySmoke = 166;
|
||||||
|
static const int _lppBinarySound = 167;
|
||||||
|
static const int _lppBinaryTamper = 168;
|
||||||
|
static const int _lppBinaryVibration = 169;
|
||||||
|
static const int _lppBinaryWindow = 170;
|
||||||
|
static const int _lppButtonEvent = 171;
|
||||||
|
static const int _lppDimmer = 172;
|
||||||
|
static const int _lppUv = 173;
|
||||||
|
static const int _lppLightLevel = 174;
|
||||||
|
static const int _lppPm25 = 175;
|
||||||
|
static const int _lppPm10 = 176;
|
||||||
|
static const int _lppCo2 = 177;
|
||||||
|
static const int _lppTvoc = 178;
|
||||||
|
static const int _lppRpm = 179;
|
||||||
|
static const int _lppConductivity = 180;
|
||||||
|
static const int _lppRotation = 181;
|
||||||
|
static const int _lppDuration = 182;
|
||||||
|
static const int _lppAcceleration = 183;
|
||||||
|
static const int _lppGyroRate = 184;
|
||||||
|
static const int _lppVolume = 185;
|
||||||
|
static const int _lppFlowRate = 186;
|
||||||
|
static const int _lppVolumeStorage = 187;
|
||||||
|
static const int _lppWater = 188;
|
||||||
|
static const int _lppGasVolume = 189;
|
||||||
|
static const int _lppMass = 190;
|
||||||
|
static const int _lppSignedSpeed = 191;
|
||||||
|
static const int _lppSignedPower = 192;
|
||||||
|
static const int _lppSignedCurrent = 193;
|
||||||
|
|
||||||
/// Parse Cayenne LPP data into ContactTelemetry
|
/// Parse Cayenne LPP data into ContactTelemetry
|
||||||
static ContactTelemetry parse(Uint8List data) {
|
static ContactTelemetry parse(Uint8List data) {
|
||||||
debugPrint(' [CayenneLPP] Parsing LPP data...');
|
debugPrint(' [CayenneLPP] Parsing LPP data...');
|
||||||
@@ -18,14 +88,18 @@ class CayenneLppParser {
|
|||||||
LatLng? gpsLocation;
|
LatLng? gpsLocation;
|
||||||
double? batteryPercentage;
|
double? batteryPercentage;
|
||||||
double? batteryMilliVolts;
|
double? batteryMilliVolts;
|
||||||
|
double? deferredBatteryMilliVolts;
|
||||||
|
double? deferredBatteryPercentage;
|
||||||
double? temperature;
|
double? temperature;
|
||||||
double? humidity;
|
double? humidity;
|
||||||
double? pressure;
|
double? pressure;
|
||||||
final extraSensorData = <String, dynamic>{};
|
final extraSensorData = <String, dynamic>{};
|
||||||
|
bool sawNonSelfChannel = false;
|
||||||
|
|
||||||
int fieldCount = 0;
|
int fieldCount = 0;
|
||||||
while (reader.hasRemaining) {
|
while (reader.hasRemaining) {
|
||||||
if (fieldCount > 0 && _isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
if (fieldCount > 0 &&
|
||||||
|
_isZeroPaddedTail(data, reader.remainingBytesCount)) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' Detected zero-padded telemetry tail, stopping parse at position '
|
' Detected zero-padded telemetry tail, stopping parse at position '
|
||||||
'${data.length - reader.remainingBytesCount}',
|
'${data.length - reader.remainingBytesCount}',
|
||||||
@@ -41,6 +115,9 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
final channel = reader.readByte();
|
final channel = reader.readByte();
|
||||||
debugPrint(' Channel: $channel');
|
debugPrint(' Channel: $channel');
|
||||||
|
if (channel != _selfTelemetryChannel) {
|
||||||
|
sawNonSelfChannel = true;
|
||||||
|
}
|
||||||
|
|
||||||
final type = reader.readByte();
|
final type = reader.readByte();
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -65,14 +142,20 @@ class CayenneLppParser {
|
|||||||
final value = rawValue / 100.0;
|
final value = rawValue / 100.0;
|
||||||
debugPrint(' Analog Input (raw): $rawValue');
|
debugPrint(' Analog Input (raw): $rawValue');
|
||||||
debugPrint(' Analog Input (volts): ${value}V');
|
debugPrint(' Analog Input (volts): ${value}V');
|
||||||
extraSensorData['analog_input_$channel'] = value;
|
if (_isDedicatedBatteryChannel(channel)) {
|
||||||
// If this is a battery reading
|
|
||||||
if (channel == 0 || channel == 1) {
|
|
||||||
batteryMilliVolts = value * 1000;
|
batteryMilliVolts = value * 1000;
|
||||||
batteryPercentage = _calculateBatteryPercentage(value);
|
batteryPercentage = _calculateBatteryPercentage(value);
|
||||||
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
|
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||||
);
|
);
|
||||||
|
} else if (_isDeferredBatteryChannel(channel)) {
|
||||||
|
deferredBatteryMilliVolts = value * 1000;
|
||||||
|
deferredBatteryPercentage = _calculateBatteryPercentage(value);
|
||||||
|
extraSensorData['analog_input_$channel'] = value;
|
||||||
|
} else {
|
||||||
|
extraSensorData['analog_input_$channel'] = value;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -85,7 +168,7 @@ class CayenneLppParser {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppIlluminanceSensor:
|
case MeshCoreConstants.lppIlluminanceSensor:
|
||||||
final value = reader.readUInt16BE();
|
final value = reader.readUInt16BE().toDouble();
|
||||||
debugPrint(' Illuminance: $value lux');
|
debugPrint(' Illuminance: $value lux');
|
||||||
extraSensorData['illuminance_$channel'] = value;
|
extraSensorData['illuminance_$channel'] = value;
|
||||||
break;
|
break;
|
||||||
@@ -98,18 +181,36 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
case MeshCoreConstants.lppTemperatureSensor:
|
case MeshCoreConstants.lppTemperatureSensor:
|
||||||
final rawValue = reader.readInt16BE();
|
final rawValue = reader.readInt16BE();
|
||||||
temperature = rawValue / 10.0;
|
final value = rawValue / 10.0;
|
||||||
debugPrint(' Temperature (raw): $rawValue');
|
debugPrint(' Temperature (raw): $rawValue');
|
||||||
debugPrint(
|
debugPrint(' Temperature: ${value.toStringAsFixed(1)}°C');
|
||||||
' Temperature: ${temperature.toStringAsFixed(1)}°C',
|
if (channel == _selfTelemetryChannel) {
|
||||||
);
|
temperature = value;
|
||||||
|
extraSensorData[_sourceChannelKey('temperature')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['temperature_$channel'] = value;
|
||||||
|
if (temperature == null) {
|
||||||
|
temperature = value;
|
||||||
|
extraSensorData[_sourceChannelKey('temperature')] = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppHumiditySensor:
|
case MeshCoreConstants.lppHumiditySensor:
|
||||||
final rawValue = reader.readByte();
|
final rawValue = reader.readByte();
|
||||||
humidity = rawValue / 2.0;
|
final value = rawValue / 2.0;
|
||||||
debugPrint(' Humidity (raw): $rawValue');
|
debugPrint(' Humidity (raw): $rawValue');
|
||||||
debugPrint(' Humidity: ${humidity.toStringAsFixed(1)}%');
|
debugPrint(' Humidity: ${value.toStringAsFixed(1)}%');
|
||||||
|
if (channel == _selfTelemetryChannel) {
|
||||||
|
humidity = value;
|
||||||
|
extraSensorData[_sourceChannelKey('humidity')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['humidity_$channel'] = value;
|
||||||
|
if (humidity == null) {
|
||||||
|
humidity = value;
|
||||||
|
extraSensorData[_sourceChannelKey('humidity')] = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppAccelerometer:
|
case MeshCoreConstants.lppAccelerometer:
|
||||||
@@ -126,9 +227,19 @@ class CayenneLppParser {
|
|||||||
|
|
||||||
case MeshCoreConstants.lppBarometer:
|
case MeshCoreConstants.lppBarometer:
|
||||||
final rawValue = reader.readUInt16BE();
|
final rawValue = reader.readUInt16BE();
|
||||||
pressure = rawValue / 10.0;
|
final value = rawValue / 10.0;
|
||||||
debugPrint(' Barometer (raw): $rawValue');
|
debugPrint(' Barometer (raw): $rawValue');
|
||||||
debugPrint(' Barometer: ${pressure.toStringAsFixed(1)} hPa');
|
debugPrint(' Barometer: ${value.toStringAsFixed(1)} hPa');
|
||||||
|
if (channel == _selfTelemetryChannel) {
|
||||||
|
pressure = value;
|
||||||
|
extraSensorData[_sourceChannelKey('pressure')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['pressure_$channel'] = value;
|
||||||
|
if (pressure == null) {
|
||||||
|
pressure = value;
|
||||||
|
extraSensorData[_sourceChannelKey('pressure')] = channel;
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppVoltageSensor:
|
case MeshCoreConstants.lppVoltageSensor:
|
||||||
@@ -136,12 +247,21 @@ class CayenneLppParser {
|
|||||||
final value = rawValue / 100.0;
|
final value = rawValue / 100.0;
|
||||||
debugPrint(' Voltage (raw): $rawValue');
|
debugPrint(' Voltage (raw): $rawValue');
|
||||||
debugPrint(' Voltage: ${value}V');
|
debugPrint(' Voltage: ${value}V');
|
||||||
// Treat voltage sensor as battery reading
|
if (_isDedicatedBatteryChannel(channel)) {
|
||||||
batteryMilliVolts = value * 1000;
|
batteryMilliVolts = value * 1000;
|
||||||
batteryPercentage = _calculateBatteryPercentage(value);
|
batteryPercentage = _calculateBatteryPercentage(value);
|
||||||
debugPrint(
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
extraSensorData[_sourceChannelKey('voltage')] = channel;
|
||||||
);
|
debugPrint(
|
||||||
|
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||||
|
);
|
||||||
|
} else if (_isDeferredBatteryChannel(channel)) {
|
||||||
|
deferredBatteryMilliVolts = value * 1000;
|
||||||
|
deferredBatteryPercentage = _calculateBatteryPercentage(value);
|
||||||
|
extraSensorData['voltage_$channel'] = value;
|
||||||
|
} else {
|
||||||
|
extraSensorData['voltage_$channel'] = value;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case MeshCoreConstants.lppGyrometer:
|
case MeshCoreConstants.lppGyrometer:
|
||||||
@@ -194,15 +314,328 @@ class CayenneLppParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
gpsLocation = LatLng(lat, lon);
|
gpsLocation = LatLng(lat, lon);
|
||||||
|
extraSensorData[_sourceChannelKey('gps')] = channel;
|
||||||
extraSensorData['altitude_$channel'] = alt;
|
extraSensorData['altitude_$channel'] = alt;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case _lppGenericSensor:
|
||||||
|
final value = _readUInt32BE(reader).toDouble();
|
||||||
|
debugPrint(' Generic Sensor: $value');
|
||||||
|
extraSensorData['generic_sensor_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppCurrent:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Current (raw): $rawValue');
|
||||||
|
debugPrint(' Current: ${value}A');
|
||||||
|
extraSensorData['current_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppFrequency:
|
||||||
|
final value = _readUInt32BE(reader).toDouble();
|
||||||
|
debugPrint(' Frequency: ${value}Hz');
|
||||||
|
extraSensorData['frequency_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPercentage:
|
||||||
|
final value = reader.readByte().toDouble();
|
||||||
|
debugPrint(' Percentage: $value%');
|
||||||
|
if (_isDedicatedBatteryChannel(channel) || _isDeferredBatteryChannel(channel)) {
|
||||||
|
batteryPercentage = value;
|
||||||
|
extraSensorData[_sourceChannelKey('battery')] = channel;
|
||||||
|
} else {
|
||||||
|
extraSensorData['percentage_$channel'] = value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppAltitude:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue.toDouble();
|
||||||
|
debugPrint(' Altitude: ${value}m');
|
||||||
|
extraSensorData['altitude_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppConcentration:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Concentration: ${value}ppm');
|
||||||
|
extraSensorData['concentration_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPower:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Power: ${value}W');
|
||||||
|
extraSensorData['power_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSpeed:
|
||||||
|
final rawValue = reader.readUInt16BE();
|
||||||
|
final value = rawValue / 100.0;
|
||||||
|
debugPrint(' Speed: ${value}m/s');
|
||||||
|
extraSensorData['speed_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDistance:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Distance: ${value}m');
|
||||||
|
extraSensorData['distance_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppEnergy:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Energy: ${value}kWh');
|
||||||
|
extraSensorData['energy_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDirection:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Direction: $value°');
|
||||||
|
extraSensorData['direction_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppUnixTime:
|
||||||
|
final value = _readUInt32BE(reader);
|
||||||
|
debugPrint(' Unix time: $value');
|
||||||
|
extraSensorData['unixtime_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppColour:
|
||||||
|
final red = reader.readByte();
|
||||||
|
final green = reader.readByte();
|
||||||
|
final blue = reader.readByte();
|
||||||
|
debugPrint(' Colour: r=$red, g=$green, b=$blue');
|
||||||
|
extraSensorData['colour_$channel'] = {
|
||||||
|
'r': red,
|
||||||
|
'g': green,
|
||||||
|
'b': blue,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppGust:
|
||||||
|
final rawValue = reader.readUInt16BE();
|
||||||
|
final value = rawValue / 100.0;
|
||||||
|
debugPrint(' Gust: ${value}m/s');
|
||||||
|
extraSensorData['gust_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDewPoint:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue / 10.0;
|
||||||
|
debugPrint(' Dew point: ${value.toStringAsFixed(1)}°C');
|
||||||
|
extraSensorData['dew_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppRain:
|
||||||
|
final rawValue = reader.readUInt16BE();
|
||||||
|
final value = rawValue / 10.0;
|
||||||
|
debugPrint(' Rain: ${value}mm');
|
||||||
|
extraSensorData['rain_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppBinaryBool:
|
||||||
|
case _lppBinaryPowerSwitch:
|
||||||
|
case _lppBinaryOpen:
|
||||||
|
case _lppBinaryBatteryLow:
|
||||||
|
case _lppBinaryCharging:
|
||||||
|
case _lppBinaryCarbonMonoxide:
|
||||||
|
case _lppBinaryCold:
|
||||||
|
case _lppBinaryConnectivity:
|
||||||
|
case _lppBinaryDoor:
|
||||||
|
case _lppBinaryGarageDoor:
|
||||||
|
case _lppBinaryGas:
|
||||||
|
case _lppBinaryHeat:
|
||||||
|
case _lppBinaryLight:
|
||||||
|
case _lppBinaryLock:
|
||||||
|
case _lppBinaryMoisture:
|
||||||
|
case _lppBinaryMotion:
|
||||||
|
case _lppBinaryMoving:
|
||||||
|
case _lppBinaryOccupancy:
|
||||||
|
case _lppBinaryPlug:
|
||||||
|
case _lppBinaryPresence:
|
||||||
|
case _lppBinaryProblem:
|
||||||
|
case _lppBinaryRunning:
|
||||||
|
case _lppBinarySafety:
|
||||||
|
case _lppBinarySmoke:
|
||||||
|
case _lppBinarySound:
|
||||||
|
case _lppBinaryTamper:
|
||||||
|
case _lppBinaryVibration:
|
||||||
|
case _lppBinaryWindow:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Binary state: $value');
|
||||||
|
extraSensorData['${_binaryMetricKeyForType(type)}_$channel'] =
|
||||||
|
value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppButtonEvent:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Button event: $value');
|
||||||
|
extraSensorData['button_event_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDimmer:
|
||||||
|
final value = _readInt8(reader);
|
||||||
|
debugPrint(' Dimmer: $value');
|
||||||
|
extraSensorData['dimmer_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppUv:
|
||||||
|
final value = reader.readByte() / 10.0;
|
||||||
|
debugPrint(' UV index: $value');
|
||||||
|
extraSensorData['uv_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppLightLevel:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Light level: $value');
|
||||||
|
extraSensorData['light_level_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPm25:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' PM2.5: $value');
|
||||||
|
extraSensorData['pm25_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppPm10:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' PM10: $value');
|
||||||
|
extraSensorData['pm10_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppCo2:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' CO2: $value');
|
||||||
|
extraSensorData['co2_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppTvoc:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' TVOC: $value');
|
||||||
|
extraSensorData['tvoc_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppRpm:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' RPM: $value');
|
||||||
|
extraSensorData['rpm_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppConductivity:
|
||||||
|
final value = reader.readUInt16BE().toDouble();
|
||||||
|
debugPrint(' Conductivity: $value');
|
||||||
|
extraSensorData['conductivity_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppRotation:
|
||||||
|
final rawValue = reader.readInt16BE();
|
||||||
|
final value = rawValue / 10.0;
|
||||||
|
debugPrint(' Rotation: $value');
|
||||||
|
extraSensorData['rotation_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppDuration:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Duration: $value s');
|
||||||
|
extraSensorData['duration_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppAcceleration:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000000.0;
|
||||||
|
debugPrint(' Acceleration: $value');
|
||||||
|
extraSensorData['acceleration_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppGyroRate:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Gyro rate: $value');
|
||||||
|
extraSensorData['gyro_rate_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppVolume:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Volume: $value');
|
||||||
|
extraSensorData['volume_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppFlowRate:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Flow rate: $value');
|
||||||
|
extraSensorData['flow_rate_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppVolumeStorage:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Storage volume: $value');
|
||||||
|
extraSensorData['volume_storage_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppWater:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Water: $value');
|
||||||
|
extraSensorData['water_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppGasVolume:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Gas volume: $value');
|
||||||
|
extraSensorData['gas_volume_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppMass:
|
||||||
|
final rawValue = _readUInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Mass: $value');
|
||||||
|
extraSensorData['mass_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSignedSpeed:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000000.0;
|
||||||
|
debugPrint(' Signed speed: $value');
|
||||||
|
extraSensorData['signed_speed_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSignedPower:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 100.0;
|
||||||
|
debugPrint(' Signed power: $value');
|
||||||
|
extraSensorData['signed_power_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSignedCurrent:
|
||||||
|
final rawValue = _readInt32BE(reader);
|
||||||
|
final value = rawValue / 1000.0;
|
||||||
|
debugPrint(' Signed current: $value');
|
||||||
|
extraSensorData['signed_current_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case _lppSwitch:
|
||||||
|
final value = reader.readByte();
|
||||||
|
debugPrint(' Switch: $value');
|
||||||
|
extraSensorData['switch_$channel'] = value;
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
debugPrint(
|
final size = _payloadSizeForType(type);
|
||||||
' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes',
|
if (size == null || reader.remainingBytesCount < size) {
|
||||||
);
|
debugPrint(
|
||||||
// Unknown type, skip remaining to avoid parsing errors
|
' ⚠️ Unknown type $type with unsupported size, stopping parse',
|
||||||
reader.skip(reader.remainingBytesCount);
|
);
|
||||||
|
reader.skip(reader.remainingBytesCount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
debugPrint(' ⚠️ Unknown type $type, skipping $size bytes');
|
||||||
|
reader.skip(size);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -212,6 +645,21 @@ class CayenneLppParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (batteryMilliVolts == null &&
|
||||||
|
batteryPercentage == null &&
|
||||||
|
deferredBatteryMilliVolts != null &&
|
||||||
|
deferredBatteryPercentage != null &&
|
||||||
|
!sawNonSelfChannel) {
|
||||||
|
batteryMilliVolts = deferredBatteryMilliVolts;
|
||||||
|
batteryPercentage = deferredBatteryPercentage;
|
||||||
|
extraSensorData[_sourceChannelKey('battery')] = _selfTelemetryChannel;
|
||||||
|
extraSensorData[_sourceChannelKey('voltage')] = _selfTelemetryChannel;
|
||||||
|
debugPrint(
|
||||||
|
' Promoted self-channel voltage to battery: '
|
||||||
|
'${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint(' Parsed $fieldCount fields');
|
debugPrint(' Parsed $fieldCount fields');
|
||||||
debugPrint(' ✅ [CayenneLPP] Parsing complete');
|
debugPrint(' ✅ [CayenneLPP] Parsing complete');
|
||||||
debugPrint(
|
debugPrint(
|
||||||
@@ -258,6 +706,165 @@ class CayenneLppParser {
|
|||||||
return ((voltage - 3.0) / 1.2) * 100.0;
|
return ((voltage - 3.0) / 1.2) * 100.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool _isDedicatedBatteryChannel(int channel) => channel == 0;
|
||||||
|
|
||||||
|
static bool _isDeferredBatteryChannel(int channel) =>
|
||||||
|
channel == _selfTelemetryChannel;
|
||||||
|
|
||||||
|
static int _readUInt32BE(BufferReader reader) {
|
||||||
|
final bytes = reader.readBytes(4);
|
||||||
|
return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _readInt32BE(BufferReader reader) {
|
||||||
|
final value = _readUInt32BE(reader);
|
||||||
|
if ((value & 0x80000000) != 0) {
|
||||||
|
return value - 0x100000000;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _readInt8(BufferReader reader) {
|
||||||
|
final value = reader.readByte();
|
||||||
|
if ((value & 0x80) != 0) {
|
||||||
|
return value - 0x100;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _sourceChannelKey(String fieldKey) =>
|
||||||
|
'__source_channel:$fieldKey';
|
||||||
|
|
||||||
|
static String _binaryMetricKeyForType(int type) {
|
||||||
|
switch (type) {
|
||||||
|
case _lppBinaryBool:
|
||||||
|
return 'binary_bool';
|
||||||
|
case _lppBinaryPowerSwitch:
|
||||||
|
return 'binary_power_switch';
|
||||||
|
case _lppBinaryOpen:
|
||||||
|
return 'binary_open';
|
||||||
|
case _lppBinaryBatteryLow:
|
||||||
|
return 'binary_battery_low';
|
||||||
|
case _lppBinaryCharging:
|
||||||
|
return 'binary_charging';
|
||||||
|
case _lppBinaryCarbonMonoxide:
|
||||||
|
return 'binary_carbon_monoxide';
|
||||||
|
case _lppBinaryCold:
|
||||||
|
return 'binary_cold';
|
||||||
|
case _lppBinaryConnectivity:
|
||||||
|
return 'binary_connectivity';
|
||||||
|
case _lppBinaryDoor:
|
||||||
|
return 'binary_door';
|
||||||
|
case _lppBinaryGarageDoor:
|
||||||
|
return 'binary_garage_door';
|
||||||
|
case _lppBinaryGas:
|
||||||
|
return 'binary_gas';
|
||||||
|
case _lppBinaryHeat:
|
||||||
|
return 'binary_heat';
|
||||||
|
case _lppBinaryLight:
|
||||||
|
return 'binary_light';
|
||||||
|
case _lppBinaryLock:
|
||||||
|
return 'binary_lock';
|
||||||
|
case _lppBinaryMoisture:
|
||||||
|
return 'binary_moisture';
|
||||||
|
case _lppBinaryMotion:
|
||||||
|
return 'binary_motion';
|
||||||
|
case _lppBinaryMoving:
|
||||||
|
return 'binary_moving';
|
||||||
|
case _lppBinaryOccupancy:
|
||||||
|
return 'binary_occupancy';
|
||||||
|
case _lppBinaryPlug:
|
||||||
|
return 'binary_plug';
|
||||||
|
case _lppBinaryPresence:
|
||||||
|
return 'binary_presence';
|
||||||
|
case _lppBinaryProblem:
|
||||||
|
return 'binary_problem';
|
||||||
|
case _lppBinaryRunning:
|
||||||
|
return 'binary_running';
|
||||||
|
case _lppBinarySafety:
|
||||||
|
return 'binary_safety';
|
||||||
|
case _lppBinarySmoke:
|
||||||
|
return 'binary_smoke';
|
||||||
|
case _lppBinarySound:
|
||||||
|
return 'binary_sound';
|
||||||
|
case _lppBinaryTamper:
|
||||||
|
return 'binary_tamper';
|
||||||
|
case _lppBinaryVibration:
|
||||||
|
return 'binary_vibration';
|
||||||
|
case _lppBinaryWindow:
|
||||||
|
return 'binary_window';
|
||||||
|
}
|
||||||
|
return 'binary_state';
|
||||||
|
}
|
||||||
|
|
||||||
|
static int? _payloadSizeForType(int type) {
|
||||||
|
if (type >= _lppBinaryBool && type <= _lppBinaryWindow) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
switch (type) {
|
||||||
|
case MeshCoreConstants.lppDigitalInput:
|
||||||
|
case MeshCoreConstants.lppDigitalOutput:
|
||||||
|
case MeshCoreConstants.lppPresenceSensor:
|
||||||
|
case MeshCoreConstants.lppHumiditySensor:
|
||||||
|
case _lppPercentage:
|
||||||
|
case _lppSwitch:
|
||||||
|
case _lppButtonEvent:
|
||||||
|
case _lppDimmer:
|
||||||
|
case _lppUv:
|
||||||
|
case _lppLightLevel:
|
||||||
|
return 1;
|
||||||
|
case MeshCoreConstants.lppAnalogInput:
|
||||||
|
case MeshCoreConstants.lppAnalogOutput:
|
||||||
|
case MeshCoreConstants.lppIlluminanceSensor:
|
||||||
|
case MeshCoreConstants.lppTemperatureSensor:
|
||||||
|
case MeshCoreConstants.lppBarometer:
|
||||||
|
case MeshCoreConstants.lppVoltageSensor:
|
||||||
|
case _lppCurrent:
|
||||||
|
case _lppAltitude:
|
||||||
|
case _lppConcentration:
|
||||||
|
case _lppPower:
|
||||||
|
case _lppSpeed:
|
||||||
|
case _lppDirection:
|
||||||
|
case _lppGust:
|
||||||
|
case _lppDewPoint:
|
||||||
|
case _lppRain:
|
||||||
|
case _lppPm25:
|
||||||
|
case _lppPm10:
|
||||||
|
case _lppCo2:
|
||||||
|
case _lppTvoc:
|
||||||
|
case _lppRpm:
|
||||||
|
case _lppConductivity:
|
||||||
|
case _lppRotation:
|
||||||
|
return 2;
|
||||||
|
case MeshCoreConstants.lppAccelerometer:
|
||||||
|
case MeshCoreConstants.lppGyrometer:
|
||||||
|
return 6;
|
||||||
|
case MeshCoreConstants.lppGps:
|
||||||
|
return 9;
|
||||||
|
case _lppGenericSensor:
|
||||||
|
case _lppFrequency:
|
||||||
|
case _lppDistance:
|
||||||
|
case _lppEnergy:
|
||||||
|
case _lppUnixTime:
|
||||||
|
case _lppDuration:
|
||||||
|
case _lppAcceleration:
|
||||||
|
case _lppGyroRate:
|
||||||
|
case _lppVolume:
|
||||||
|
case _lppFlowRate:
|
||||||
|
case _lppVolumeStorage:
|
||||||
|
case _lppWater:
|
||||||
|
case _lppGasVolume:
|
||||||
|
case _lppMass:
|
||||||
|
case _lppSignedSpeed:
|
||||||
|
case _lppSignedPower:
|
||||||
|
case _lppSignedCurrent:
|
||||||
|
return 4;
|
||||||
|
case _lppColour:
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) {
|
||||||
final start = data.length - remainingBytes;
|
final start = data.length - remainingBytes;
|
||||||
for (int i = start; i < data.length; i++) {
|
for (int i = start; i < data.length; i++) {
|
||||||
|
|||||||
11
lib/services/compass_support.dart
Normal file
11
lib/services/compass_support.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:flutter/foundation.dart'
|
||||||
|
show TargetPlatform, defaultTargetPlatform, kIsWeb;
|
||||||
|
|
||||||
|
class CompassSupport {
|
||||||
|
const CompassSupport._();
|
||||||
|
|
||||||
|
static bool get isAvailable =>
|
||||||
|
!kIsWeb &&
|
||||||
|
(defaultTargetPlatform == TargetPlatform.android ||
|
||||||
|
defaultTargetPlatform == TargetPlatform.iOS);
|
||||||
|
}
|
||||||
336
lib/services/contact_route_resolver.dart
Normal file
336
lib/services/contact_route_resolver.dart
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
import '../models/contact.dart';
|
||||||
|
|
||||||
|
class ResolvedContactRoutePlan {
|
||||||
|
final List<String> tokens;
|
||||||
|
final List<Contact> selectedContacts;
|
||||||
|
final String summary;
|
||||||
|
|
||||||
|
const ResolvedContactRoutePlan({
|
||||||
|
required this.tokens,
|
||||||
|
required this.selectedContacts,
|
||||||
|
required this.summary,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get canonicalText => tokens.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactRouteResolver {
|
||||||
|
static const Distance _distance = Distance();
|
||||||
|
|
||||||
|
const ContactRouteResolver._();
|
||||||
|
|
||||||
|
static ResolvedContactRoutePlan? resolveAutomaticRoute({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required Contact recipient,
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final recipientLocation = recipient.displayLocation;
|
||||||
|
if (recipientLocation == null) return null;
|
||||||
|
|
||||||
|
final repeaters = availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.isRepeater &&
|
||||||
|
contact.displayLocation != null &&
|
||||||
|
contact.publicKeyHex != recipient.publicKeyHex,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
if (repeaters.isEmpty) return null;
|
||||||
|
|
||||||
|
final recipientLatLng = LatLng(
|
||||||
|
recipientLocation.latitude,
|
||||||
|
recipientLocation.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final routedCandidates =
|
||||||
|
repeaters
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.routeHasPath && contact.routeHashSize == hashSize,
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort(
|
||||||
|
(a, b) =>
|
||||||
|
_scoreKnownRouteRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: a,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
hashSize: hashSize,
|
||||||
|
).compareTo(
|
||||||
|
_scoreKnownRouteRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: b,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
hashSize: hashSize,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (routedCandidates.isNotEmpty) {
|
||||||
|
final anchor = routedCandidates.first;
|
||||||
|
final tokens = <String>[
|
||||||
|
...anchor.routeCanonicalText
|
||||||
|
.split(',')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.map((token) => token.toUpperCase()),
|
||||||
|
_tokenFor(anchor, hashSize),
|
||||||
|
];
|
||||||
|
final selectedContacts = _matchContactsForTokens(
|
||||||
|
tokens,
|
||||||
|
availableContacts: repeaters,
|
||||||
|
);
|
||||||
|
return ResolvedContactRoutePlan(
|
||||||
|
tokens: _dedupeTokens(tokens),
|
||||||
|
selectedContacts: selectedContacts,
|
||||||
|
summary: 'Resolved via known repeater route',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final corridorRepeaters = List<Contact>.from(repeaters)
|
||||||
|
..sort((a, b) {
|
||||||
|
final progressA = _progressAlongSegment(
|
||||||
|
point: LatLng(
|
||||||
|
a.displayLocation!.latitude,
|
||||||
|
a.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
start: senderLocation,
|
||||||
|
end: recipientLatLng,
|
||||||
|
);
|
||||||
|
final progressB = _progressAlongSegment(
|
||||||
|
point: LatLng(
|
||||||
|
b.displayLocation!.latitude,
|
||||||
|
b.displayLocation!.longitude,
|
||||||
|
),
|
||||||
|
start: senderLocation,
|
||||||
|
end: recipientLatLng,
|
||||||
|
);
|
||||||
|
final progressCompare = progressA.compareTo(progressB);
|
||||||
|
if (progressCompare != 0) return progressCompare;
|
||||||
|
return _scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: a,
|
||||||
|
).compareTo(
|
||||||
|
_scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLatLng,
|
||||||
|
repeater: b,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
final selected = <Contact>[];
|
||||||
|
var currentPoint = senderLocation;
|
||||||
|
var currentDistanceToRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
senderLocation,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
final maxCorridorDistance = math.max(
|
||||||
|
1500.0,
|
||||||
|
currentDistanceToRecipient * 0.22,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final repeater in corridorRepeaters) {
|
||||||
|
if (selected.length >= 4) break;
|
||||||
|
final repeaterPoint = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
final distanceToSegment = _distanceToSegmentMeters(
|
||||||
|
repeaterPoint,
|
||||||
|
senderLocation,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
if (distanceToSegment > maxCorridorDistance) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextDistanceToRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
repeaterPoint,
|
||||||
|
recipientLatLng,
|
||||||
|
);
|
||||||
|
if (nextDistanceToRecipient >= currentDistanceToRecipient - 300) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final distanceFromCurrent = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
currentPoint,
|
||||||
|
repeaterPoint,
|
||||||
|
);
|
||||||
|
if (distanceFromCurrent < 100) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.add(repeater);
|
||||||
|
currentPoint = repeaterPoint;
|
||||||
|
currentDistanceToRecipient = nextDistanceToRecipient;
|
||||||
|
|
||||||
|
if (currentDistanceToRecipient < 2500) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolvedContactRoutePlan(
|
||||||
|
tokens: selected.map((contact) => _tokenFor(contact, hashSize)).toList(),
|
||||||
|
selectedContacts: selected,
|
||||||
|
summary: 'Resolved from repeater locations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<Contact> _matchContactsForTokens(
|
||||||
|
List<String> tokens, {
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
}) {
|
||||||
|
final matches = <Contact>[];
|
||||||
|
final seen = <String>{};
|
||||||
|
for (final token in tokens) {
|
||||||
|
final match = availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||||
|
)
|
||||||
|
.firstOrNull;
|
||||||
|
if (match != null && seen.add(match.publicKeyHex)) {
|
||||||
|
matches.add(match);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _dedupeTokens(List<String> tokens) {
|
||||||
|
final result = <String>[];
|
||||||
|
for (final token in tokens) {
|
||||||
|
if (result.isEmpty || result.last != token) {
|
||||||
|
result.add(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _tokenFor(Contact contact, int hashSize) {
|
||||||
|
final hex = contact.publicKeyHex.toUpperCase();
|
||||||
|
final length = hashSize * 2;
|
||||||
|
return hex.length < length ? hex : hex.substring(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreRepeater({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required LatLng recipientLocation,
|
||||||
|
required Contact repeater,
|
||||||
|
}) {
|
||||||
|
final point = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
final toRecipient = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
point,
|
||||||
|
recipientLocation,
|
||||||
|
);
|
||||||
|
final corridor = _distanceToSegmentMeters(
|
||||||
|
point,
|
||||||
|
senderLocation,
|
||||||
|
recipientLocation,
|
||||||
|
);
|
||||||
|
return (toRecipient * 0.75) + (corridor * 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _scoreKnownRouteRepeater({
|
||||||
|
required LatLng senderLocation,
|
||||||
|
required LatLng recipientLocation,
|
||||||
|
required Contact repeater,
|
||||||
|
required List<Contact> availableContacts,
|
||||||
|
required int hashSize,
|
||||||
|
}) {
|
||||||
|
final baseScore = _scoreRepeater(
|
||||||
|
senderLocation: senderLocation,
|
||||||
|
recipientLocation: recipientLocation,
|
||||||
|
repeater: repeater,
|
||||||
|
);
|
||||||
|
final repeaterPoint = LatLng(
|
||||||
|
repeater.displayLocation!.latitude,
|
||||||
|
repeater.displayLocation!.longitude,
|
||||||
|
);
|
||||||
|
|
||||||
|
final chainContacts = repeater.routeCanonicalText
|
||||||
|
.split(',')
|
||||||
|
.where((token) => token.isNotEmpty)
|
||||||
|
.map(
|
||||||
|
(token) => availableContacts
|
||||||
|
.where(
|
||||||
|
(contact) =>
|
||||||
|
contact.displayLocation != null &&
|
||||||
|
contact.publicKeyHex.toUpperCase().startsWith(
|
||||||
|
token.toUpperCase(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.firstOrNull,
|
||||||
|
)
|
||||||
|
.whereType<Contact>()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final chainEnd = chainContacts.isNotEmpty
|
||||||
|
? LatLng(
|
||||||
|
chainContacts.last.displayLocation!.latitude,
|
||||||
|
chainContacts.last.displayLocation!.longitude,
|
||||||
|
)
|
||||||
|
: senderLocation;
|
||||||
|
final chainGap = _distance.as(LengthUnit.Meter, chainEnd, repeaterPoint);
|
||||||
|
final senderGap = _distance.as(
|
||||||
|
LengthUnit.Meter,
|
||||||
|
senderLocation,
|
||||||
|
repeaterPoint,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (chainGap * 0.55) + (baseScore * 0.35) + (senderGap * 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _progressAlongSegment({
|
||||||
|
required LatLng point,
|
||||||
|
required LatLng start,
|
||||||
|
required LatLng end,
|
||||||
|
}) {
|
||||||
|
final dx = end.longitude - start.longitude;
|
||||||
|
final dy = end.latitude - start.latitude;
|
||||||
|
final lengthSquared = (dx * dx) + (dy * dy);
|
||||||
|
if (lengthSquared == 0) return 0;
|
||||||
|
return (((point.longitude - start.longitude) * dx) +
|
||||||
|
((point.latitude - start.latitude) * dy)) /
|
||||||
|
lengthSquared;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _distanceToSegmentMeters(LatLng p, LatLng a, LatLng b) {
|
||||||
|
final ax = a.longitude;
|
||||||
|
final ay = a.latitude;
|
||||||
|
final bx = b.longitude;
|
||||||
|
final by = b.latitude;
|
||||||
|
final px = p.longitude;
|
||||||
|
final py = p.latitude;
|
||||||
|
|
||||||
|
final abx = bx - ax;
|
||||||
|
final aby = by - ay;
|
||||||
|
final apx = px - ax;
|
||||||
|
final apy = py - ay;
|
||||||
|
final ab2 = abx * abx + aby * aby;
|
||||||
|
if (ab2 == 0) {
|
||||||
|
return _distance.as(LengthUnit.Meter, a, p);
|
||||||
|
}
|
||||||
|
var t = (apx * abx + apy * aby) / ab2;
|
||||||
|
t = t.clamp(0.0, 1.0);
|
||||||
|
final closest = LatLng(ay + aby * t, ax + abx * t);
|
||||||
|
return _distance.as(LengthUnit.Meter, closest, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,25 @@ 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 '../models/contact_group.dart';
|
||||||
|
import 'profiles_feature_service.dart';
|
||||||
import '../utils/key_comparison.dart';
|
import '../utils/key_comparison.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting contacts to local storage
|
/// Service for persisting contacts to local storage
|
||||||
class ContactStorageService {
|
class ContactStorageService {
|
||||||
static const String _contactsKey = 'stored_contacts';
|
static const String _contactsKey = 'stored_contacts';
|
||||||
|
static const String _contactGroupsKey = 'stored_contact_groups';
|
||||||
|
static const String _pendingAdvertsKey = 'stored_pending_adverts';
|
||||||
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
static const int _maxStoredContacts = 500; // Store up to 500 contacts
|
||||||
|
static const int _maxStoredPendingAdverts = 500;
|
||||||
|
|
||||||
|
String _key(String baseKey, {String? namespace}) {
|
||||||
|
return ProfileStorageScope.scopedKey(baseKey, namespace: namespace);
|
||||||
|
}
|
||||||
|
|
||||||
/// Save contacts to persistent storage
|
/// Save contacts to persistent storage
|
||||||
Future<void> saveContacts(List<Contact> contacts) async {
|
Future<void> saveContacts(List<Contact> contacts, {String? namespace}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
@@ -26,7 +35,10 @@ class ContactStorageService {
|
|||||||
: jsonList;
|
: jsonList;
|
||||||
|
|
||||||
final jsonString = jsonEncode(limitedList);
|
final jsonString = jsonEncode(limitedList);
|
||||||
await prefs.setString(_contactsKey, jsonString);
|
await prefs.setString(
|
||||||
|
_key(_contactsKey, namespace: namespace),
|
||||||
|
jsonString,
|
||||||
|
);
|
||||||
|
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [ContactStorage] Saved ${limitedList.length} contacts to storage',
|
'✅ [ContactStorage] Saved ${limitedList.length} contacts to storage',
|
||||||
@@ -38,10 +50,15 @@ class ContactStorageService {
|
|||||||
|
|
||||||
/// Load contacts from persistent storage
|
/// Load contacts from persistent storage
|
||||||
/// [excludePublicKey] - optional public key to exclude (e.g., device's own key)
|
/// [excludePublicKey] - optional public key to exclude (e.g., device's own key)
|
||||||
Future<List<Contact>> loadContacts({Uint8List? excludePublicKey}) async {
|
Future<List<Contact>> loadContacts({
|
||||||
|
Uint8List? excludePublicKey,
|
||||||
|
String? namespace,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_contactsKey);
|
final jsonString = prefs.getString(
|
||||||
|
_key(_contactsKey, namespace: namespace),
|
||||||
|
);
|
||||||
|
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
debugPrint('ℹ️ [ContactStorage] No stored contacts found');
|
debugPrint('ℹ️ [ContactStorage] No stored contacts found');
|
||||||
@@ -80,21 +97,158 @@ class ContactStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all stored contacts
|
/// Clear all stored contacts
|
||||||
Future<void> clearContacts() async {
|
Future<void> clearContacts({String? namespace}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_contactsKey);
|
await prefs.remove(_key(_contactsKey, namespace: namespace));
|
||||||
debugPrint('✅ [ContactStorage] Cleared all stored contacts');
|
debugPrint('✅ [ContactStorage] Cleared all stored contacts');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ContactStorage] Error clearing contacts: $e');
|
debugPrint('❌ [ContactStorage] Error clearing contacts: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
Future<void> saveContactGroups(
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
List<SavedContactGroup> groups, {
|
||||||
|
String? namespace,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_contactsKey);
|
final jsonString = jsonEncode(
|
||||||
|
groups.map((group) => _contactGroupToJson(group)).toList(),
|
||||||
|
);
|
||||||
|
await prefs.setString(
|
||||||
|
_key(_contactGroupsKey, namespace: namespace),
|
||||||
|
jsonString,
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'✅ [ContactStorage] Saved ${groups.length} contact groups to storage',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error saving contact groups: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SavedContactGroup>> loadContactGroups({String? namespace}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(
|
||||||
|
_key(_contactGroupsKey, namespace: namespace),
|
||||||
|
);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
|
return jsonList
|
||||||
|
.map((json) => _contactGroupFromJson(json as Map<String, dynamic>))
|
||||||
|
.whereType<SavedContactGroup>()
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading contact groups: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearContactGroups({String? namespace}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_key(_contactGroupsKey, namespace: namespace));
|
||||||
|
debugPrint('✅ [ContactStorage] Cleared all contact groups');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error clearing contact groups: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> savePendingAdverts(
|
||||||
|
List<Map<String, dynamic>> adverts, {
|
||||||
|
String? namespace,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final limitedList = adverts.length > _maxStoredPendingAdverts
|
||||||
|
? adverts.sublist(adverts.length - _maxStoredPendingAdverts)
|
||||||
|
: adverts;
|
||||||
|
await prefs.setString(
|
||||||
|
_key(_pendingAdvertsKey, namespace: namespace),
|
||||||
|
jsonEncode(limitedList),
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'✅ [ContactStorage] Saved ${limitedList.length} pending adverts to storage',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error saving pending adverts: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> loadPendingAdverts({
|
||||||
|
String? namespace,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(
|
||||||
|
_key(_pendingAdvertsKey, namespace: namespace),
|
||||||
|
);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonList = jsonDecode(jsonString) as List<dynamic>;
|
||||||
|
return jsonList.whereType<Map<String, dynamic>>().toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading pending adverts: $e');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearPendingAdverts({String? namespace}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_key(_pendingAdvertsKey, namespace: namespace));
|
||||||
|
debugPrint('✅ [ContactStorage] Cleared all stored pending adverts');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error clearing pending adverts: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Favorites ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static const String _favoritesKey = 'contact_favorites';
|
||||||
|
|
||||||
|
Future<void> saveFavorites(
|
||||||
|
Set<String> publicKeyHexes, {
|
||||||
|
String? namespace,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(
|
||||||
|
_key(_favoritesKey, namespace: namespace),
|
||||||
|
publicKeyHexes.toList(),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error saving favorites: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Set<String>> loadFavorites({String? namespace}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final list = prefs.getStringList(
|
||||||
|
_key(_favoritesKey, namespace: namespace),
|
||||||
|
);
|
||||||
|
return list?.toSet() ?? {};
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error loading favorites: $e');
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get storage statistics
|
||||||
|
Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(
|
||||||
|
_key(_contactsKey, namespace: namespace),
|
||||||
|
);
|
||||||
|
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
return {'contactCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||||
@@ -123,6 +277,7 @@ class ContactStorageService {
|
|||||||
'outPathLen': contact.outPathLen,
|
'outPathLen': contact.outPathLen,
|
||||||
'outPath': base64Encode(contact.outPath),
|
'outPath': base64Encode(contact.outPath),
|
||||||
'advName': contact.advName,
|
'advName': contact.advName,
|
||||||
|
'nameOverride': contact.nameOverride,
|
||||||
'lastAdvert': contact.lastAdvert,
|
'lastAdvert': contact.lastAdvert,
|
||||||
'advLat': contact.advLat,
|
'advLat': contact.advLat,
|
||||||
'advLon': contact.advLon,
|
'advLon': contact.advLon,
|
||||||
@@ -130,6 +285,15 @@ class ContactStorageService {
|
|||||||
'telemetry': contact.telemetry != null
|
'telemetry': contact.telemetry != null
|
||||||
? _telemetryToJson(contact.telemetry!)
|
? _telemetryToJson(contact.telemetry!)
|
||||||
: null,
|
: null,
|
||||||
|
'advertHistory': contact.advertHistory
|
||||||
|
.map(
|
||||||
|
(point) => {
|
||||||
|
'lat': point.location.latitude,
|
||||||
|
'lon': point.location.longitude,
|
||||||
|
'tsMillis': point.timestamp.millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +309,7 @@ class ContactStorageService {
|
|||||||
outPathLen: json['outPathLen'] as int,
|
outPathLen: json['outPathLen'] as int,
|
||||||
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
|
outPath: Uint8List.fromList(base64Decode(json['outPath'] as String)),
|
||||||
advName: json['advName'] as String,
|
advName: json['advName'] as String,
|
||||||
|
nameOverride: json['nameOverride'] as String?,
|
||||||
lastAdvert: json['lastAdvert'] as int,
|
lastAdvert: json['lastAdvert'] as int,
|
||||||
advLat: json['advLat'] as int,
|
advLat: json['advLat'] as int,
|
||||||
advLon: json['advLon'] as int,
|
advLon: json['advLon'] as int,
|
||||||
@@ -152,6 +317,7 @@ class ContactStorageService {
|
|||||||
telemetry: json['telemetry'] != null
|
telemetry: json['telemetry'] != null
|
||||||
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
|
? _telemetryFromJson(json['telemetry'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
|
advertHistory: _advertHistoryFromJson(json['advertHistory']),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
|
debugPrint('❌ [ContactStorage] Error parsing contact from JSON: $e');
|
||||||
@@ -203,4 +369,56 @@ class ContactStorageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<AdvertLocation> _advertHistoryFromJson(dynamic json) {
|
||||||
|
if (json is! List) return [];
|
||||||
|
final result = <AdvertLocation>[];
|
||||||
|
for (final item in json) {
|
||||||
|
if (item is! Map<String, dynamic>) continue;
|
||||||
|
final lat = item['lat'];
|
||||||
|
final lon = item['lon'];
|
||||||
|
final tsMillis = item['tsMillis'];
|
||||||
|
if (lat is! num || lon is! num || tsMillis is! int) continue;
|
||||||
|
result.add(
|
||||||
|
AdvertLocation(
|
||||||
|
location: LatLng(lat.toDouble(), lon.toDouble()),
|
||||||
|
timestamp: DateTime.fromMillisecondsSinceEpoch(tsMillis),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _contactGroupToJson(SavedContactGroup group) {
|
||||||
|
return {
|
||||||
|
'id': group.id,
|
||||||
|
'sectionKey': group.sectionKey,
|
||||||
|
'label': group.label,
|
||||||
|
'query': group.query,
|
||||||
|
'createdAtMillis': group.createdAt.millisecondsSinceEpoch,
|
||||||
|
'matchPrefixes': group.matchPrefixes,
|
||||||
|
'isAutoGroup': group.isAutoGroup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
SavedContactGroup? _contactGroupFromJson(Map<String, dynamic> json) {
|
||||||
|
try {
|
||||||
|
return SavedContactGroup(
|
||||||
|
id: json['id'] as String,
|
||||||
|
sectionKey: json['sectionKey'] as String,
|
||||||
|
label: json['label'] as String,
|
||||||
|
query: json['query'] as String,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
json['createdAtMillis'] as int,
|
||||||
|
),
|
||||||
|
matchPrefixes: (json['matchPrefixes'] as List<dynamic>?)
|
||||||
|
?.map((value) => value as String)
|
||||||
|
.toList(),
|
||||||
|
isAutoGroup: json['isAutoGroup'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [ContactStorage] Error parsing contact group: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
140
lib/services/device_config_applicator.dart
Normal file
140
lib/services/device_config_applicator.dart
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import '../models/channel.dart';
|
||||||
|
import '../models/config_profile.dart';
|
||||||
|
import '../providers/channels_provider.dart';
|
||||||
|
import '../providers/connection_provider.dart';
|
||||||
|
|
||||||
|
class DeviceConfigApplicator {
|
||||||
|
ConfigProfileSections capture({
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
required ChannelsProvider channelsProvider,
|
||||||
|
}) {
|
||||||
|
final deviceInfo = connectionProvider.deviceInfo;
|
||||||
|
return ConfigProfileSections(
|
||||||
|
deviceConfig: DeviceConfigProfileSection(
|
||||||
|
frequencyKhz: deviceInfo.radioFreq,
|
||||||
|
bandwidth: deviceInfo.radioBw,
|
||||||
|
spreadingFactor: deviceInfo.radioSf,
|
||||||
|
codingRate: deviceInfo.radioCr,
|
||||||
|
repeatEnabled: deviceInfo.clientRepeat,
|
||||||
|
txPower: deviceInfo.txPower,
|
||||||
|
telemetryModes: deviceInfo.telemetryModes,
|
||||||
|
advertLocationPolicy: deviceInfo.advertLocPolicy,
|
||||||
|
multiAcks: deviceInfo.multiAcks,
|
||||||
|
manualAddContacts: deviceInfo.manualAddContacts,
|
||||||
|
autoAddUsers: deviceInfo.autoAddUsers,
|
||||||
|
autoAddRepeaters: deviceInfo.autoAddRepeaters,
|
||||||
|
autoAddRoomServers: deviceInfo.autoAddRoomServers,
|
||||||
|
autoAddSensors: deviceInfo.autoAddSensors,
|
||||||
|
autoAddOverwriteOldest: deviceInfo.autoAddOverwriteOldest,
|
||||||
|
publicLatitude: _decodeAdvertCoordinate(deviceInfo.advLat),
|
||||||
|
publicLongitude: _decodeAdvertCoordinate(deviceInfo.advLon),
|
||||||
|
),
|
||||||
|
channels: channelsProvider.channels,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> apply(
|
||||||
|
ConfigProfile profile, {
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
required ChannelsProvider channelsProvider,
|
||||||
|
}) async {
|
||||||
|
if (!connectionProvider.deviceInfo.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final deviceConfig = profile.sections.deviceConfig;
|
||||||
|
if (deviceConfig != null && !deviceConfig.isEmpty) {
|
||||||
|
if (deviceConfig.txPower != null) {
|
||||||
|
await connectionProvider.setTxPower(deviceConfig.txPower!);
|
||||||
|
}
|
||||||
|
if (deviceConfig.telemetryModes != null ||
|
||||||
|
deviceConfig.advertLocationPolicy != null ||
|
||||||
|
deviceConfig.manualAddContacts != null ||
|
||||||
|
deviceConfig.multiAcks != null) {
|
||||||
|
await connectionProvider.setOtherParams(
|
||||||
|
manualAddContacts: (deviceConfig.manualAddContacts ?? false) ? 1 : 0,
|
||||||
|
telemetryModes: deviceConfig.telemetryModes ?? 0,
|
||||||
|
advertLocationPolicy: deviceConfig.advertLocationPolicy ?? 0,
|
||||||
|
multiAcks: deviceConfig.multiAcks ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (deviceConfig.autoAddUsers != null &&
|
||||||
|
deviceConfig.autoAddRepeaters != null &&
|
||||||
|
deviceConfig.autoAddRoomServers != null &&
|
||||||
|
deviceConfig.autoAddSensors != null &&
|
||||||
|
deviceConfig.autoAddOverwriteOldest != null) {
|
||||||
|
await connectionProvider.setAutoaddConfig(
|
||||||
|
autoAddUsers: deviceConfig.autoAddUsers!,
|
||||||
|
autoAddRepeaters: deviceConfig.autoAddRepeaters!,
|
||||||
|
autoAddRoomServers: deviceConfig.autoAddRoomServers!,
|
||||||
|
autoAddSensors: deviceConfig.autoAddSensors!,
|
||||||
|
overwriteOldest: deviceConfig.autoAddOverwriteOldest!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (deviceConfig.publicLatitude != null &&
|
||||||
|
deviceConfig.publicLongitude != null) {
|
||||||
|
await connectionProvider.setAdvertLatLon(
|
||||||
|
latitude: deviceConfig.publicLatitude!,
|
||||||
|
longitude: deviceConfig.publicLongitude!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (deviceConfig.frequencyKhz != null &&
|
||||||
|
deviceConfig.bandwidth != null &&
|
||||||
|
deviceConfig.spreadingFactor != null &&
|
||||||
|
deviceConfig.codingRate != null) {
|
||||||
|
await connectionProvider.setRadioParams(
|
||||||
|
frequency: deviceConfig.frequencyKhz!,
|
||||||
|
bandwidth: deviceConfig.bandwidth!,
|
||||||
|
spreadingFactor: deviceConfig.spreadingFactor!,
|
||||||
|
codingRate: deviceConfig.codingRate!,
|
||||||
|
repeat: deviceConfig.repeatEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _applyChannels(
|
||||||
|
channels: profile.sections.channels,
|
||||||
|
connectionProvider: connectionProvider,
|
||||||
|
channelsProvider: channelsProvider,
|
||||||
|
);
|
||||||
|
await connectionProvider.refreshDeviceInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _applyChannels({
|
||||||
|
required List<Channel> channels,
|
||||||
|
required ConnectionProvider connectionProvider,
|
||||||
|
required ChannelsProvider channelsProvider,
|
||||||
|
}) async {
|
||||||
|
if (channels.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final desired = {for (final channel in channels) channel.index: channel};
|
||||||
|
|
||||||
|
for (final channel in channels) {
|
||||||
|
await connectionProvider.setChannelSlot(
|
||||||
|
channelIdx: channel.index,
|
||||||
|
channelName: channel.name,
|
||||||
|
secret: Uint8List.fromList(channel.secret),
|
||||||
|
);
|
||||||
|
channelsProvider.addOrUpdateChannelObject(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final existing in channelsProvider.channels) {
|
||||||
|
if (existing.index == 0 || desired.containsKey(existing.index)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await connectionProvider.deleteChannel(existing.index);
|
||||||
|
channelsProvider.removeChannel(existing.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _decodeAdvertCoordinate(int? value) {
|
||||||
|
if (value == null || value == 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value / 1e6;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'profiles_feature_service.dart';
|
||||||
|
|
||||||
/// Stores user-selected image compression settings.
|
/// Stores user-selected image compression settings.
|
||||||
class ImagePreferences {
|
class ImagePreferences {
|
||||||
@@ -17,44 +18,53 @@ class ImagePreferences {
|
|||||||
|
|
||||||
static Future<int> getMaxSize() async {
|
static Future<int> getMaxSize() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final value = prefs.getInt(_maxSizeKey) ?? defaultMaxSize;
|
final value =
|
||||||
|
prefs.getInt(ProfileStorageScope.scopedKey(_maxSizeKey)) ??
|
||||||
|
defaultMaxSize;
|
||||||
return supportedSizes.contains(value) ? value : defaultMaxSize;
|
return supportedSizes.contains(value) ? value : defaultMaxSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setMaxSize(int size) async {
|
static Future<void> setMaxSize(int size) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setInt(_maxSizeKey, size);
|
await prefs.setInt(ProfileStorageScope.scopedKey(_maxSizeKey), size);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<int> getCompression() async {
|
static Future<int> getCompression() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final value = prefs.getInt(_qualityKey) ?? defaultQuality;
|
final value =
|
||||||
|
prefs.getInt(ProfileStorageScope.scopedKey(_qualityKey)) ??
|
||||||
|
defaultQuality;
|
||||||
return value.clamp(10, 90);
|
return value.clamp(10, 90);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setCompression(int compression) async {
|
static Future<void> setCompression(int compression) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setInt(_qualityKey, compression.clamp(10, 90));
|
await prefs.setInt(
|
||||||
|
ProfileStorageScope.scopedKey(_qualityKey),
|
||||||
|
compression.clamp(10, 90),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> getGrayscale() async {
|
static Future<bool> getGrayscale() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getBool(_grayscaleKey) ?? defaultGrayscale;
|
return prefs.getBool(ProfileStorageScope.scopedKey(_grayscaleKey)) ??
|
||||||
|
defaultGrayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setGrayscale(bool value) async {
|
static Future<void> setGrayscale(bool value) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_grayscaleKey, value);
|
await prefs.setBool(ProfileStorageScope.scopedKey(_grayscaleKey), value);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> getUltraMode() async {
|
static Future<bool> getUltraMode() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getBool(_ultraModeKey) ?? defaultUltraMode;
|
return prefs.getBool(ProfileStorageScope.scopedKey(_ultraModeKey)) ??
|
||||||
|
defaultUltraMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setUltraMode(bool value) async {
|
static Future<void> setUltraMode(bool value) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_ultraModeKey, value);
|
await prefs.setBool(ProfileStorageScope.scopedKey(_ultraModeKey), value);
|
||||||
}
|
}
|
||||||
|
|
||||||
static int effectiveMaxSize(
|
static int effectiveMaxSize(
|
||||||
|
|||||||
262
lib/services/live_traffic_summary.dart
Normal file
262
lib/services/live_traffic_summary.dart
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import '../models/ble_packet_log.dart';
|
||||||
|
import '../utils/log_rx_route_decoder.dart';
|
||||||
|
|
||||||
|
enum LiveTrafficBusyness { quiet, active, busy }
|
||||||
|
|
||||||
|
class LiveTrafficEntry {
|
||||||
|
final BlePacketLog log;
|
||||||
|
final DecodedLogRxRoute? route;
|
||||||
|
|
||||||
|
const LiveTrafficEntry({required this.log, required this.route});
|
||||||
|
|
||||||
|
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
|
||||||
|
|
||||||
|
int? get hopCount => route?.hopCount;
|
||||||
|
|
||||||
|
String get payloadLabel {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null) {
|
||||||
|
return log.responseCode != null ? log.opcodeName : 'Unknown';
|
||||||
|
}
|
||||||
|
return payloadTypeLabel(decodedRoute.payloadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get payloadMeaning {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null) return null;
|
||||||
|
return payloadTypeMeaning(decodedRoute.payloadType);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get routePreview {
|
||||||
|
final decodedRoute = route;
|
||||||
|
if (decodedRoute == null || decodedRoute.hopHashes.isEmpty) {
|
||||||
|
return 'Direct packet';
|
||||||
|
}
|
||||||
|
return decodedRoute.hopHashes
|
||||||
|
.map((hashHex) => '0x${hashHex.toUpperCase()}')
|
||||||
|
.join(' -> ');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String payloadTypeLabel(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'Request';
|
||||||
|
case 0x01:
|
||||||
|
return 'Response';
|
||||||
|
case 0x02:
|
||||||
|
return 'Text message';
|
||||||
|
case 0x03:
|
||||||
|
return 'Ack';
|
||||||
|
case 0x04:
|
||||||
|
return 'Advertisement';
|
||||||
|
case 0x05:
|
||||||
|
return 'Group text';
|
||||||
|
case 0x06:
|
||||||
|
return 'Group datagram';
|
||||||
|
case 0x07:
|
||||||
|
return 'Anonymous request';
|
||||||
|
case 0x08:
|
||||||
|
return 'Returned path';
|
||||||
|
case 0x09:
|
||||||
|
return 'Trace path';
|
||||||
|
case 0x0A:
|
||||||
|
return 'Multipart packet';
|
||||||
|
case 0x0B:
|
||||||
|
return 'Control packet';
|
||||||
|
default:
|
||||||
|
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String payloadTypeMeaning(int payloadType) {
|
||||||
|
switch (payloadType) {
|
||||||
|
case 0x00:
|
||||||
|
return 'Request (destination/source hashes + MAC)';
|
||||||
|
case 0x01:
|
||||||
|
return 'Response to Request or Anonymous request';
|
||||||
|
case 0x02:
|
||||||
|
return 'Plain text message';
|
||||||
|
case 0x03:
|
||||||
|
return 'Simple acknowledgement';
|
||||||
|
case 0x04:
|
||||||
|
return 'Node advertisement';
|
||||||
|
case 0x05:
|
||||||
|
return 'Unverified group text message';
|
||||||
|
case 0x06:
|
||||||
|
return 'Unverified group datagram';
|
||||||
|
case 0x07:
|
||||||
|
return 'Generic anonymous request';
|
||||||
|
case 0x08:
|
||||||
|
return 'Returned path payload';
|
||||||
|
case 0x09:
|
||||||
|
return 'Trace path collecting hop SNR';
|
||||||
|
case 0x0A:
|
||||||
|
return 'One packet from a multipart set';
|
||||||
|
case 0x0B:
|
||||||
|
return 'Control or discovery packet';
|
||||||
|
default:
|
||||||
|
return 'protocol payload';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveTrafficSnapshot {
|
||||||
|
final DateTime windowStart;
|
||||||
|
final Duration windowDuration;
|
||||||
|
final int packetsPerMinute;
|
||||||
|
final int rxCount;
|
||||||
|
final int txCount;
|
||||||
|
final int totalCount;
|
||||||
|
final double? avgSnrDb;
|
||||||
|
final double? latestSnrDb;
|
||||||
|
final double? avgRssiDbm;
|
||||||
|
final int? latestRssiDbm;
|
||||||
|
final int multiHopCount;
|
||||||
|
final double? avgHopCount;
|
||||||
|
final List<LiveTrafficEntry> visibleEntries;
|
||||||
|
final LiveTrafficBusyness busyness;
|
||||||
|
|
||||||
|
const LiveTrafficSnapshot({
|
||||||
|
required this.windowStart,
|
||||||
|
required this.windowDuration,
|
||||||
|
required this.packetsPerMinute,
|
||||||
|
required this.rxCount,
|
||||||
|
required this.txCount,
|
||||||
|
required this.totalCount,
|
||||||
|
required this.avgSnrDb,
|
||||||
|
required this.latestSnrDb,
|
||||||
|
required this.avgRssiDbm,
|
||||||
|
required this.latestRssiDbm,
|
||||||
|
required this.multiHopCount,
|
||||||
|
required this.avgHopCount,
|
||||||
|
required this.visibleEntries,
|
||||||
|
required this.busyness,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class LiveTrafficSummary {
|
||||||
|
static const Duration rollingWindow = Duration(seconds: 60);
|
||||||
|
static const int maxVisibleEntries = 120;
|
||||||
|
static const int logRxDataResponseCode = 0x88;
|
||||||
|
|
||||||
|
const LiveTrafficSummary._();
|
||||||
|
|
||||||
|
static bool isRxDataLog(BlePacketLog log) {
|
||||||
|
return log.direction == PacketDirection.rx &&
|
||||||
|
log.responseCode == logRxDataResponseCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int countRxDataLogs(Iterable<BlePacketLog> logs, {DateTime? since}) {
|
||||||
|
return logs.where((log) {
|
||||||
|
return isRxDataLog(log) &&
|
||||||
|
(since == null || !log.timestamp.isBefore(since));
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
static LiveTrafficSnapshot fromLogs(
|
||||||
|
Iterable<BlePacketLog> logs, {
|
||||||
|
required DateTime now,
|
||||||
|
DateTime? clearedAt,
|
||||||
|
int? preferredHashSize,
|
||||||
|
Duration window = rollingWindow,
|
||||||
|
String? packetTypeFilter,
|
||||||
|
}) {
|
||||||
|
final windowStart = now.subtract(window);
|
||||||
|
final effectiveStart = clearedAt != null && clearedAt.isAfter(windowStart)
|
||||||
|
? clearedAt
|
||||||
|
: windowStart;
|
||||||
|
|
||||||
|
final recentLogs =
|
||||||
|
logs
|
||||||
|
.where(
|
||||||
|
(log) =>
|
||||||
|
isRxDataLog(log) && !log.timestamp.isBefore(effectiveStart),
|
||||||
|
)
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||||
|
|
||||||
|
final entries = <LiveTrafficEntry>[];
|
||||||
|
for (final log in recentLogs) {
|
||||||
|
final route = LogRxRouteDecoder.decode(
|
||||||
|
log.rawData,
|
||||||
|
preferredHashSize: preferredHashSize,
|
||||||
|
);
|
||||||
|
entries.add(LiveTrafficEntry(log: log, route: route));
|
||||||
|
}
|
||||||
|
|
||||||
|
final filteredEntries = packetTypeFilter == null
|
||||||
|
? entries
|
||||||
|
: entries
|
||||||
|
.where((entry) => entry.payloadLabel == packetTypeFilter)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var rxCount = 0;
|
||||||
|
var snrCount = 0;
|
||||||
|
var snrSum = 0.0;
|
||||||
|
var rssiCount = 0;
|
||||||
|
var rssiSum = 0.0;
|
||||||
|
double? latestSnrDb;
|
||||||
|
int? latestRssiDbm;
|
||||||
|
var multiHopCount = 0;
|
||||||
|
var hopCountTotal = 0;
|
||||||
|
var hopCountSamples = 0;
|
||||||
|
|
||||||
|
for (final entry in filteredEntries) {
|
||||||
|
rxCount += 1;
|
||||||
|
|
||||||
|
final rxInfo = entry.log.logRxDataInfo;
|
||||||
|
if (rxInfo?.snrDb != null) {
|
||||||
|
snrCount += 1;
|
||||||
|
snrSum += rxInfo!.snrDb!;
|
||||||
|
latestSnrDb = rxInfo.snrDb!;
|
||||||
|
}
|
||||||
|
if (rxInfo?.rssiDbm != null) {
|
||||||
|
rssiCount += 1;
|
||||||
|
rssiSum += rxInfo!.rssiDbm!.toDouble();
|
||||||
|
latestRssiDbm = rxInfo.rssiDbm!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final route = entry.route;
|
||||||
|
if (route != null && route.hopCount > 0) {
|
||||||
|
hopCountSamples += 1;
|
||||||
|
hopCountTotal += route.hopCount;
|
||||||
|
if (route.hopCount > 1) {
|
||||||
|
multiHopCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final visibleEntries = filteredEntries.reversed
|
||||||
|
.take(maxVisibleEntries)
|
||||||
|
.toList();
|
||||||
|
const txCount = 0;
|
||||||
|
final totalCount = rxCount;
|
||||||
|
final packetsPerMinute =
|
||||||
|
((totalCount * Duration.secondsPerMinute) / window.inSeconds).round();
|
||||||
|
|
||||||
|
return LiveTrafficSnapshot(
|
||||||
|
windowStart: effectiveStart,
|
||||||
|
windowDuration: window,
|
||||||
|
packetsPerMinute: packetsPerMinute,
|
||||||
|
rxCount: rxCount,
|
||||||
|
txCount: txCount,
|
||||||
|
totalCount: totalCount,
|
||||||
|
avgSnrDb: snrCount == 0 ? null : snrSum / snrCount,
|
||||||
|
latestSnrDb: latestSnrDb,
|
||||||
|
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
|
||||||
|
latestRssiDbm: latestRssiDbm,
|
||||||
|
multiHopCount: multiHopCount,
|
||||||
|
avgHopCount: hopCountSamples == 0
|
||||||
|
? null
|
||||||
|
: hopCountTotal / hopCountSamples,
|
||||||
|
visibleEntries: visibleEntries,
|
||||||
|
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static LiveTrafficBusyness _busynessForPacketsPerMinute(int ppm) {
|
||||||
|
if (ppm <= 5) return LiveTrafficBusyness.quiet;
|
||||||
|
if (ppm <= 20) return LiveTrafficBusyness.active;
|
||||||
|
return LiveTrafficBusyness.busy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:meshcore_client/meshcore_client.dart';
|
import 'package:meshcore_client/meshcore_client.dart';
|
||||||
|
import 'profiles_feature_service.dart';
|
||||||
|
|
||||||
/// Centralized location tracking service for MeshCore SAR
|
/// Centralized location tracking service for MeshCore SAR
|
||||||
///
|
///
|
||||||
@@ -18,6 +20,14 @@ import 'package:meshcore_client/meshcore_client.dart';
|
|||||||
/// - MeshCore mesh network integration
|
/// - MeshCore mesh network integration
|
||||||
/// - Real-time position updates via callbacks
|
/// - Real-time position updates via callbacks
|
||||||
class LocationTrackingService {
|
class LocationTrackingService {
|
||||||
|
static const double _defaultFastLocationMovementThresholdMeters = 10.0;
|
||||||
|
static const double _minFastLocationMovementThresholdMeters = 10.0;
|
||||||
|
static const int _defaultFastLocationActiveCadenceSeconds = 10;
|
||||||
|
static const int _minFastLocationActiveCadenceSeconds = 10;
|
||||||
|
static const int _maxFastLocationActiveCadenceSeconds = 31;
|
||||||
|
static const Duration _fastLocationMinimumUpdateInterval = Duration(
|
||||||
|
seconds: 31,
|
||||||
|
);
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Singleton Pattern
|
// Singleton Pattern
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -47,6 +57,11 @@ class LocationTrackingService {
|
|||||||
'fast_location_movement_threshold_meters';
|
'fast_location_movement_threshold_meters';
|
||||||
static const String _prefKeyFastActiveCadence =
|
static const String _prefKeyFastActiveCadence =
|
||||||
'fast_location_active_cadence_seconds';
|
'fast_location_active_cadence_seconds';
|
||||||
|
static const String _prefKeyFastChannelIdx = 'fast_location_channel_idx';
|
||||||
|
|
||||||
|
String _scopedKey(String baseKey) {
|
||||||
|
return ProfileStorageScope.scopedKey(baseKey);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Configuration Properties
|
// Configuration Properties
|
||||||
@@ -68,10 +83,15 @@ class LocationTrackingService {
|
|||||||
bool fastLocationUpdatesEnabled = false;
|
bool fastLocationUpdatesEnabled = false;
|
||||||
|
|
||||||
/// Distance threshold for fast GPS updates
|
/// Distance threshold for fast GPS updates
|
||||||
double fastLocationMovementThresholdMeters = 10.0;
|
double fastLocationMovementThresholdMeters =
|
||||||
|
_defaultFastLocationMovementThresholdMeters;
|
||||||
|
|
||||||
/// Cadence for active-use fast GPS updates
|
/// Cadence for active-use fast GPS updates
|
||||||
int fastLocationActiveCadenceSeconds = 10;
|
int fastLocationActiveCadenceSeconds =
|
||||||
|
_defaultFastLocationActiveCadenceSeconds;
|
||||||
|
|
||||||
|
/// Target channel index for fast GPS updates; null means disabled/unset.
|
||||||
|
int? fastLocationChannelIdx;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// State Properties
|
// State Properties
|
||||||
@@ -148,6 +168,10 @@ class LocationTrackingService {
|
|||||||
/// Check if location permissions are granted
|
/// Check if location permissions are granted
|
||||||
Future<bool> checkPermissions() async {
|
Future<bool> checkPermissions() async {
|
||||||
final permission = await Geolocator.checkPermission();
|
final permission = await Geolocator.checkPermission();
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return permission == LocationPermission.always;
|
||||||
|
}
|
||||||
|
|
||||||
return permission == LocationPermission.always ||
|
return permission == LocationPermission.always ||
|
||||||
permission == LocationPermission.whileInUse;
|
permission == LocationPermission.whileInUse;
|
||||||
}
|
}
|
||||||
@@ -181,6 +205,16 @@ class LocationTrackingService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Platform.isIOS && permission != LocationPermission.always) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
if (permission != LocationPermission.always) {
|
||||||
|
onError?.call(
|
||||||
|
'Background tracking on iOS requires "Always" location access. Enable it in Settings.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Location permissions granted');
|
debugPrint('✅ [LocationTracking] Location permissions granted');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -209,9 +243,9 @@ class LocationTrackingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final position = await Geolocator.getCurrentPosition(
|
final position = await Geolocator.getCurrentPosition(
|
||||||
locationSettings: LocationSettings(
|
locationSettings: _buildLocationSettings(
|
||||||
accuracy: LocationAccuracy.best,
|
|
||||||
timeLimit: timeLimit,
|
timeLimit: timeLimit,
|
||||||
|
distanceFilter: 0,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -255,13 +289,35 @@ class LocationTrackingService {
|
|||||||
/// [distanceFilter] - Minimum distance in meters between position updates
|
/// [distanceFilter] - Minimum distance in meters between position updates
|
||||||
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
|
Stream<Position> getPositionStream({double distanceFilter = 10.0}) {
|
||||||
return Geolocator.getPositionStream(
|
return Geolocator.getPositionStream(
|
||||||
locationSettings: LocationSettings(
|
locationSettings: _buildLocationSettings(
|
||||||
accuracy: LocationAccuracy.best,
|
|
||||||
distanceFilter: distanceFilter.toInt(),
|
distanceFilter: distanceFilter.toInt(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LocationSettings _buildLocationSettings({
|
||||||
|
int distanceFilter = 10,
|
||||||
|
Duration? timeLimit,
|
||||||
|
}) {
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
return AppleSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
activityType: ActivityType.fitness,
|
||||||
|
allowBackgroundLocationUpdates: true,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
pauseLocationUpdatesAutomatically: false,
|
||||||
|
showBackgroundLocationIndicator: true,
|
||||||
|
timeLimit: timeLimit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LocationSettings(
|
||||||
|
accuracy: LocationAccuracy.best,
|
||||||
|
distanceFilter: distanceFilter,
|
||||||
|
timeLimit: timeLimit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Tracking Control
|
// Tracking Control
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -362,7 +418,7 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
// Save disabled state
|
// Save disabled state
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_prefKeyEnabled, false);
|
await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Tracking stopped');
|
debugPrint('✅ [LocationTracking] Tracking stopped');
|
||||||
}
|
}
|
||||||
@@ -440,8 +496,8 @@ class LocationTrackingService {
|
|||||||
|
|
||||||
// Save to preferences
|
// Save to preferences
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setDouble(_prefKeyLastLat, position.latitude);
|
await prefs.setDouble(_scopedKey(_prefKeyLastLat), position.latitude);
|
||||||
await prefs.setDouble(_prefKeyLastLon, position.longitude);
|
await prefs.setDouble(_scopedKey(_prefKeyLastLon), position.longitude);
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Initial position set without broadcast');
|
debugPrint('✅ [LocationTracking] Initial position set without broadcast');
|
||||||
debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s');
|
debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s');
|
||||||
@@ -476,18 +532,30 @@ class LocationTrackingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateFastLocationMovementThreshold(double meters) async {
|
Future<void> updateFastLocationMovementThreshold(double meters) async {
|
||||||
fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0);
|
fastLocationMovementThresholdMeters = meters.clamp(
|
||||||
|
_minFastLocationMovementThresholdMeters,
|
||||||
|
1000.0,
|
||||||
|
);
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
|
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
|
||||||
fastLocationActiveCadenceSeconds = seconds.clamp(5, 60);
|
fastLocationActiveCadenceSeconds = seconds.clamp(
|
||||||
|
_minFastLocationActiveCadenceSeconds,
|
||||||
|
_maxFastLocationActiveCadenceSeconds,
|
||||||
|
);
|
||||||
|
await saveSettings();
|
||||||
|
_refreshFastLocationTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateFastLocationChannelIdx(int? channelIdx) async {
|
||||||
|
fastLocationChannelIdx = channelIdx;
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
_refreshFastLocationTimer();
|
_refreshFastLocationTimer();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _evaluateFastLocationMovement(Position position) {
|
void _evaluateFastLocationMovement(Position position) {
|
||||||
if (!fastLocationUpdatesEnabled) return;
|
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
|
||||||
final previous = _lastFastLocationSentPosition;
|
final previous = _lastFastLocationSentPosition;
|
||||||
if (previous == null) {
|
if (previous == null) {
|
||||||
_emitFastLocationUpdate(position, reason: 'initial');
|
_emitFastLocationUpdate(position, reason: 'initial');
|
||||||
@@ -510,6 +578,7 @@ class LocationTrackingService {
|
|||||||
_fastLocationTimer = null;
|
_fastLocationTimer = null;
|
||||||
if (!isTracking ||
|
if (!isTracking ||
|
||||||
!fastLocationUpdatesEnabled ||
|
!fastLocationUpdatesEnabled ||
|
||||||
|
fastLocationChannelIdx == null ||
|
||||||
!_isFastLocationActiveUse) {
|
!_isFastLocationActiveUse) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -525,11 +594,16 @@ class LocationTrackingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _emitFastLocationUpdate(Position position, {required String reason}) {
|
void _emitFastLocationUpdate(Position position, {required String reason}) {
|
||||||
if (!fastLocationUpdatesEnabled) return;
|
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final previous = _lastFastLocationSentPosition;
|
final previous = _lastFastLocationSentPosition;
|
||||||
final previousTime = _lastFastLocationSentAt;
|
final previousTime = _lastFastLocationSentAt;
|
||||||
|
if (previousTime != null &&
|
||||||
|
now.difference(previousTime) < _fastLocationMinimumUpdateInterval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (previous != null && previousTime != null) {
|
if (previous != null && previousTime != null) {
|
||||||
final distance = Geolocator.distanceBetween(
|
final distance = Geolocator.distanceBetween(
|
||||||
previous.latitude,
|
previous.latitude,
|
||||||
@@ -609,17 +683,27 @@ class LocationTrackingService {
|
|||||||
Future<void> loadSettings() async {
|
Future<void> loadSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
minDistanceMeters = prefs.getDouble(_prefKeyMinDistance) ?? 5.0;
|
minDistanceMeters = prefs.getDouble(_scopedKey(_prefKeyMinDistance)) ?? 5.0;
|
||||||
maxDistanceMeters = prefs.getDouble(_prefKeyMaxDistance) ?? 100.0;
|
maxDistanceMeters =
|
||||||
minTimeIntervalSeconds = prefs.getInt(_prefKeyMinTimeInterval) ?? 30;
|
prefs.getDouble(_scopedKey(_prefKeyMaxDistance)) ?? 100.0;
|
||||||
gpsUpdateDistance = prefs.getDouble(_prefKeyGpsUpdateDistance) ?? 10.0;
|
minTimeIntervalSeconds =
|
||||||
|
prefs.getInt(_scopedKey(_prefKeyMinTimeInterval)) ?? 30;
|
||||||
|
gpsUpdateDistance =
|
||||||
|
prefs.getDouble(_scopedKey(_prefKeyGpsUpdateDistance)) ?? 10.0;
|
||||||
fastLocationUpdatesEnabled =
|
fastLocationUpdatesEnabled =
|
||||||
prefs.getBool(_prefKeyFastLocationEnabled) ?? false;
|
prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false;
|
||||||
fastLocationMovementThresholdMeters =
|
fastLocationMovementThresholdMeters =
|
||||||
(prefs.getDouble(_prefKeyFastMovementThreshold) ?? gpsUpdateDistance)
|
(prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ??
|
||||||
.clamp(1.0, 1000.0);
|
_defaultFastLocationMovementThresholdMeters)
|
||||||
|
.clamp(_minFastLocationMovementThresholdMeters, 1000.0);
|
||||||
fastLocationActiveCadenceSeconds =
|
fastLocationActiveCadenceSeconds =
|
||||||
(prefs.getInt(_prefKeyFastActiveCadence) ?? 10).clamp(5, 60);
|
(prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ??
|
||||||
|
_defaultFastLocationActiveCadenceSeconds)
|
||||||
|
.clamp(
|
||||||
|
_minFastLocationActiveCadenceSeconds,
|
||||||
|
_maxFastLocationActiveCadenceSeconds,
|
||||||
|
);
|
||||||
|
fastLocationChannelIdx = prefs.getInt(_scopedKey(_prefKeyFastChannelIdx));
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Settings loaded');
|
debugPrint('✅ [LocationTracking] Settings loaded');
|
||||||
debugPrint(' Min distance: ${minDistanceMeters}m');
|
debugPrint(' Min distance: ${minDistanceMeters}m');
|
||||||
@@ -631,29 +715,42 @@ class LocationTrackingService {
|
|||||||
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
|
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
|
||||||
);
|
);
|
||||||
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
|
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
|
||||||
|
debugPrint(' Fast channel idx: ${fastLocationChannelIdx ?? "unset"}');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save settings to SharedPreferences
|
/// Save settings to SharedPreferences
|
||||||
Future<void> saveSettings() async {
|
Future<void> saveSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
await prefs.setDouble(_prefKeyMinDistance, minDistanceMeters);
|
await prefs.setDouble(_scopedKey(_prefKeyMinDistance), minDistanceMeters);
|
||||||
await prefs.setDouble(_prefKeyMaxDistance, maxDistanceMeters);
|
await prefs.setDouble(_scopedKey(_prefKeyMaxDistance), maxDistanceMeters);
|
||||||
await prefs.setInt(_prefKeyMinTimeInterval, minTimeIntervalSeconds);
|
await prefs.setInt(
|
||||||
await prefs.setDouble(_prefKeyGpsUpdateDistance, gpsUpdateDistance);
|
_scopedKey(_prefKeyMinTimeInterval),
|
||||||
await prefs.setBool(_prefKeyEnabled, isTracking);
|
minTimeIntervalSeconds,
|
||||||
|
);
|
||||||
|
await prefs.setDouble(
|
||||||
|
_scopedKey(_prefKeyGpsUpdateDistance),
|
||||||
|
gpsUpdateDistance,
|
||||||
|
);
|
||||||
|
await prefs.setBool(_scopedKey(_prefKeyEnabled), isTracking);
|
||||||
await prefs.setBool(
|
await prefs.setBool(
|
||||||
_prefKeyFastLocationEnabled,
|
_scopedKey(_prefKeyFastLocationEnabled),
|
||||||
fastLocationUpdatesEnabled,
|
fastLocationUpdatesEnabled,
|
||||||
);
|
);
|
||||||
await prefs.setDouble(
|
await prefs.setDouble(
|
||||||
_prefKeyFastMovementThreshold,
|
_scopedKey(_prefKeyFastMovementThreshold),
|
||||||
fastLocationMovementThresholdMeters,
|
fastLocationMovementThresholdMeters,
|
||||||
);
|
);
|
||||||
await prefs.setInt(
|
await prefs.setInt(
|
||||||
_prefKeyFastActiveCadence,
|
_scopedKey(_prefKeyFastActiveCadence),
|
||||||
fastLocationActiveCadenceSeconds,
|
fastLocationActiveCadenceSeconds,
|
||||||
);
|
);
|
||||||
|
final channelKey = _scopedKey(_prefKeyFastChannelIdx);
|
||||||
|
if (fastLocationChannelIdx == null) {
|
||||||
|
await prefs.remove(channelKey);
|
||||||
|
} else {
|
||||||
|
await prefs.setInt(channelKey, fastLocationChannelIdx!);
|
||||||
|
}
|
||||||
|
|
||||||
debugPrint('✅ [LocationTracking] Settings saved');
|
debugPrint('✅ [LocationTracking] Settings saved');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,90 +41,103 @@ class MapMarkerService {
|
|||||||
Function(Contact)? onTap,
|
Function(Contact)? onTap,
|
||||||
double mapRotation = 0,
|
double mapRotation = 0,
|
||||||
Position? userPosition,
|
Position? userPosition,
|
||||||
|
Map<String, LatLng>? estimatedLocations,
|
||||||
}) {
|
}) {
|
||||||
return contacts.map((contact) {
|
return contacts
|
||||||
final location = contact.displayLocation;
|
.map((contact) {
|
||||||
if (location == null) return null;
|
final location = contact.displayLocation ??
|
||||||
|
estimatedLocations?[contact.publicKeyHex];
|
||||||
|
if (location == null) return null;
|
||||||
|
|
||||||
return Marker(
|
return Marker(
|
||||||
point: location,
|
point: location,
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 100,
|
height: 100,
|
||||||
rotate: false, // Don't rotate the entire marker with map
|
rotate: false, // Don't rotate the entire marker with map
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: -mapRotation * pi / 180,
|
angle: -mapRotation * pi / 180,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap != null ? () => onTap(contact) : null,
|
onTap: onTap != null ? () => onTap(contact) : null,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Location update time indicator
|
// Location update time indicator
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(
|
horizontal: 4,
|
||||||
color: getLocationAgeColor(contact),
|
vertical: 1,
|
||||||
borderRadius: BorderRadius.circular(3),
|
),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
child: Text(
|
color: getLocationAgeColor(contact),
|
||||||
contact.timeSinceLocationUpdate,
|
borderRadius: BorderRadius.circular(3),
|
||||||
style: const TextStyle(
|
),
|
||||||
color: Colors.white,
|
child: Text(
|
||||||
fontSize: 9,
|
contact.timeSinceLocationUpdate,
|
||||||
fontWeight: FontWeight.bold,
|
style: const TextStyle(
|
||||||
),
|
color: Colors.white,
|
||||||
),
|
fontSize: 9,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
const SizedBox(height: 2),
|
),
|
||||||
// Marker icon
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
shape: contact.type == ContactType.channel ||
|
|
||||||
contact.type == ContactType.room
|
|
||||||
? BoxShape.rectangle
|
|
||||||
: BoxShape.circle,
|
|
||||||
borderRadius: contact.type == ContactType.channel ||
|
|
||||||
contact.type == ContactType.room
|
|
||||||
? BorderRadius.circular(14)
|
|
||||||
: null,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.3),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
child: ContactAvatar(contact: contact, radius: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
// Name label (without emoji)
|
|
||||||
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(
|
|
||||||
contact.displayName,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 9,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
),
|
||||||
overflow: TextOverflow.ellipsis,
|
const SizedBox(height: 2),
|
||||||
maxLines: 1,
|
// Marker icon
|
||||||
textAlign: TextAlign.center,
|
Container(
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape:
|
||||||
|
contact.type == ContactType.channel ||
|
||||||
|
contact.type == ContactType.room
|
||||||
|
? BoxShape.rectangle
|
||||||
|
: BoxShape.circle,
|
||||||
|
borderRadius:
|
||||||
|
contact.type == ContactType.channel ||
|
||||||
|
contact.type == ContactType.room
|
||||||
|
? BorderRadius.circular(14)
|
||||||
|
: null,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: ContactAvatar(contact: contact, radius: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// Name label (without emoji)
|
||||||
|
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(
|
||||||
|
contact.displayName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
})
|
||||||
);
|
.whereType<Marker>()
|
||||||
}).whereType<Marker>().toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate markers for SAR events.
|
/// Generate markers for SAR events.
|
||||||
@@ -141,10 +154,11 @@ class MapMarkerService {
|
|||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
Function(SarMarker)? onTap,
|
Function(SarMarker)? onTap,
|
||||||
double mapRotation = 0,
|
double mapRotation = 0,
|
||||||
|
LatLng Function(LatLng point)? pointTransformer,
|
||||||
}) {
|
}) {
|
||||||
return sarMarkers.map((marker) {
|
return sarMarkers.map((marker) {
|
||||||
return Marker(
|
return Marker(
|
||||||
point: marker.location,
|
point: pointTransformer?.call(marker.location) ?? marker.location,
|
||||||
width: 90,
|
width: 90,
|
||||||
height: 100,
|
height: 100,
|
||||||
rotate: false, // Don't rotate the entire marker with map
|
rotate: false, // Don't rotate the entire marker with map
|
||||||
@@ -157,7 +171,10 @@ class MapMarkerService {
|
|||||||
children: [
|
children: [
|
||||||
// Time ago label
|
// Time ago label
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: getSarMarkerColor(marker.type),
|
color: getSarMarkerColor(marker.type),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
@@ -188,7 +205,7 @@ class MapMarkerService {
|
|||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
child: Text(
|
child: Text(
|
||||||
marker.emoji, // Use custom emoji if available
|
marker.emoji, // Use custom emoji if available
|
||||||
style: const TextStyle(fontSize: 18),
|
style: const TextStyle(fontSize: 18),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -196,13 +213,17 @@ class MapMarkerService {
|
|||||||
// Type label
|
// Type label
|
||||||
Container(
|
Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 90),
|
constraints: const BoxConstraints(maxWidth: 90),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.black.withValues(alpha: 0.7),
|
color: Colors.black.withValues(alpha: 0.7),
|
||||||
borderRadius: BorderRadius.circular(3),
|
borderRadius: BorderRadius.circular(3),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
marker.displayName, // Uses notes if available, otherwise type.displayName
|
marker
|
||||||
|
.displayName, // Uses notes if available, otherwise type.displayName
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -269,7 +290,8 @@ class MapMarkerService {
|
|||||||
final dLat = (lat2 - lat1) * pi / 180;
|
final dLat = (lat2 - lat1) * pi / 180;
|
||||||
final dLon = (lon2 - lon1) * pi / 180;
|
final dLon = (lon2 - lon1) * pi / 180;
|
||||||
|
|
||||||
final a = sin(dLat / 2) * sin(dLat / 2) +
|
final a =
|
||||||
|
sin(dLat / 2) * sin(dLat / 2) +
|
||||||
cos(lat1 * pi / 180) *
|
cos(lat1 * pi / 180) *
|
||||||
cos(lat2 * pi / 180) *
|
cos(lat2 * pi / 180) *
|
||||||
sin(dLon / 2) *
|
sin(dLon / 2) *
|
||||||
@@ -299,8 +321,8 @@ class MapMarkerService {
|
|||||||
final lat2Rad = lat2 * pi / 180;
|
final lat2Rad = lat2 * pi / 180;
|
||||||
|
|
||||||
final y = sin(dLon) * cos(lat2Rad);
|
final y = sin(dLon) * cos(lat2Rad);
|
||||||
final x = cos(lat1Rad) * sin(lat2Rad) -
|
final x =
|
||||||
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
|
||||||
|
|
||||||
final bearing = atan2(y, x) * 180 / pi;
|
final bearing = atan2(y, x) * 180 / pi;
|
||||||
return (bearing + 360) % 360;
|
return (bearing + 360) % 360;
|
||||||
@@ -368,6 +390,8 @@ class MapMarkerService {
|
|||||||
return Colors.deepPurple; // Purple for repeaters
|
return Colors.deepPurple; // Purple for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Colors.teal; // Teal for rooms
|
return Colors.teal; // Teal for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Colors.green; // Green for sensors
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Colors.orange; // Orange for channels
|
return Colors.orange; // Orange for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -389,6 +413,8 @@ class MapMarkerService {
|
|||||||
return Icons.router; // Router icon for repeaters
|
return Icons.router; // Router icon for repeaters
|
||||||
case ContactType.room:
|
case ContactType.room:
|
||||||
return Icons.forum; // Forum/chat icon for rooms
|
return Icons.forum; // Forum/chat icon for rooms
|
||||||
|
case ContactType.sensor:
|
||||||
|
return Icons.sensors; // Sensors icon for sensor nodes
|
||||||
case ContactType.channel:
|
case ContactType.channel:
|
||||||
return Icons.public; // Public icon for channels
|
return Icons.public; // Public icon for channels
|
||||||
case ContactType.none:
|
case ContactType.none:
|
||||||
@@ -466,7 +492,8 @@ class MapMarkerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (allPoints.isEmpty) {
|
if (allPoints.isEmpty) {
|
||||||
return defaultCenter ?? const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
return defaultCenter ??
|
||||||
|
const LatLng(46.0569, 14.5058); // Ljubljana, Slovenia
|
||||||
}
|
}
|
||||||
|
|
||||||
double lat = 0, lng = 0;
|
double lat = 0, lng = 0;
|
||||||
|
|||||||
99
lib/services/map_workspace_snapshot_service.dart
Normal file
99
lib/services/map_workspace_snapshot_service.dart
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../models/config_profile.dart';
|
||||||
|
import '../providers/drawing_provider.dart';
|
||||||
|
import '../providers/map_provider.dart';
|
||||||
|
import 'profiles_feature_service.dart';
|
||||||
|
|
||||||
|
class MapWorkspaceSnapshotService {
|
||||||
|
Future<MapWorkspaceProfileSection> capture({
|
||||||
|
required MapProvider mapProvider,
|
||||||
|
required DrawingProvider drawingProvider,
|
||||||
|
}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return MapWorkspaceProfileSection(
|
||||||
|
mapPrefs: {
|
||||||
|
'map_rotate_with_heading':
|
||||||
|
prefs.getBool(
|
||||||
|
ProfileStorageScope.scopedKey('map_rotate_with_heading'),
|
||||||
|
) ??
|
||||||
|
false,
|
||||||
|
'map_show_debug_info':
|
||||||
|
prefs.getBool(
|
||||||
|
ProfileStorageScope.scopedKey('map_show_debug_info'),
|
||||||
|
) ??
|
||||||
|
false,
|
||||||
|
'map_fullscreen':
|
||||||
|
prefs.getBool(ProfileStorageScope.scopedKey('map_fullscreen')) ??
|
||||||
|
false,
|
||||||
|
'map_last_latitude': prefs.getDouble(
|
||||||
|
ProfileStorageScope.scopedKey('map_last_latitude'),
|
||||||
|
),
|
||||||
|
'map_last_longitude': prefs.getDouble(
|
||||||
|
ProfileStorageScope.scopedKey('map_last_longitude'),
|
||||||
|
),
|
||||||
|
'map_last_zoom': prefs.getDouble(
|
||||||
|
ProfileStorageScope.scopedKey('map_last_zoom'),
|
||||||
|
),
|
||||||
|
'map_last_layer_type': prefs.getInt(
|
||||||
|
ProfileStorageScope.scopedKey('map_last_layer_type'),
|
||||||
|
),
|
||||||
|
'map_gps_update_distance': prefs.getDouble(
|
||||||
|
ProfileStorageScope.scopedKey('map_gps_update_distance'),
|
||||||
|
),
|
||||||
|
'background_tracking_enabled': prefs.getBool(
|
||||||
|
ProfileStorageScope.scopedKey('background_tracking_enabled'),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
drawings: drawingProvider.exportDrawingsJson(),
|
||||||
|
currentTrail: mapProvider.currentTrail?.toJson(),
|
||||||
|
trailHistory: mapProvider.trailHistory
|
||||||
|
.map((trail) => trail.toJson())
|
||||||
|
.toList(),
|
||||||
|
importedTrail: mapProvider.importedTrail?.toJson(),
|
||||||
|
isTrailVisible: mapProvider.isTrailVisible,
|
||||||
|
showCadastralOverlay: mapProvider.showCadastralOverlay,
|
||||||
|
showForestRoadsOverlay: mapProvider.showForestRoadsOverlay,
|
||||||
|
showHikingTrailsOverlay: mapProvider.showHikingTrailsOverlay,
|
||||||
|
showMainRoadsOverlay: mapProvider.showMainRoadsOverlay,
|
||||||
|
showHouseNumbersOverlay: mapProvider.showHouseNumbersOverlay,
|
||||||
|
showFireHazardZonesOverlay: mapProvider.showFireHazardZonesOverlay,
|
||||||
|
showHistoricalFiresOverlay: mapProvider.showHistoricalFiresOverlay,
|
||||||
|
showFirebreaksOverlay: mapProvider.showFirebreaksOverlay,
|
||||||
|
showKrasFireZonesOverlay: mapProvider.showKrasFireZonesOverlay,
|
||||||
|
showPlaceNamesOverlay: mapProvider.showPlaceNamesOverlay,
|
||||||
|
showMunicipalityBordersOverlay:
|
||||||
|
mapProvider.showMunicipalityBordersOverlay,
|
||||||
|
hideRepeatersOnMap: mapProvider.hideRepeatersOnMap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> apply(
|
||||||
|
MapWorkspaceProfileSection? section, {
|
||||||
|
required MapProvider mapProvider,
|
||||||
|
required DrawingProvider drawingProvider,
|
||||||
|
}) async {
|
||||||
|
if (section == null || section.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final mapPrefs = section.mapPrefs ?? const <String, dynamic>{};
|
||||||
|
for (final entry in mapPrefs.entries) {
|
||||||
|
final key = ProfileStorageScope.scopedKey(entry.key);
|
||||||
|
final value = entry.value;
|
||||||
|
if (value == null) {
|
||||||
|
await prefs.remove(key);
|
||||||
|
} else if (value is bool) {
|
||||||
|
await prefs.setBool(key, value);
|
||||||
|
} else if (value is int) {
|
||||||
|
await prefs.setInt(key, value);
|
||||||
|
} else if (value is double) {
|
||||||
|
await prefs.setDouble(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mapProvider.applyWorkspaceJson(section.toJson());
|
||||||
|
await drawingProvider.replaceDrawingsFromJson(section.drawings);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class MeshMapNode {
|
class MeshMapNode {
|
||||||
final int type;
|
final int type;
|
||||||
@@ -18,6 +20,14 @@ class MeshMapNode {
|
|||||||
required this.updatedAtMs,
|
required this.updatedAtMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get hasValidCoordinates =>
|
||||||
|
latitude >= -90 &&
|
||||||
|
latitude <= 90 &&
|
||||||
|
longitude >= -180 &&
|
||||||
|
longitude <= 180 &&
|
||||||
|
latitude != 0 &&
|
||||||
|
longitude != 0;
|
||||||
|
|
||||||
factory MeshMapNode.fromJson(Map<String, dynamic> json) {
|
factory MeshMapNode.fromJson(Map<String, dynamic> json) {
|
||||||
return MeshMapNode(
|
return MeshMapNode(
|
||||||
type: (json['type'] as num?)?.toInt() ?? 0,
|
type: (json['type'] as num?)?.toInt() ?? 0,
|
||||||
@@ -33,25 +43,36 @@ class MeshMapNode {
|
|||||||
class MeshMapNodesService {
|
class MeshMapNodesService {
|
||||||
static const String _nodesEndpoint =
|
static const String _nodesEndpoint =
|
||||||
'https://api.meshcore.nz/api/v1/map/nodes';
|
'https://api.meshcore.nz/api/v1/map/nodes';
|
||||||
static const Duration _cacheTtl = Duration(minutes: 2);
|
static const int repeaterType = 1;
|
||||||
static const Duration traceCacheTtl = Duration(minutes: 10);
|
static const Duration _cacheTtl = Duration(hours: 24);
|
||||||
|
static const Duration traceCacheTtl = _cacheTtl;
|
||||||
static const Duration traceTimeout = Duration(seconds: 30);
|
static const Duration traceTimeout = Duration(seconds: 30);
|
||||||
|
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
|
||||||
|
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
|
||||||
static List<MeshMapNode>? _cachedNodes;
|
static List<MeshMapNode>? _cachedNodes;
|
||||||
static DateTime? _cachedAt;
|
static DateTime? _cachedAt;
|
||||||
|
static Future<void>? _ongoingSync;
|
||||||
|
|
||||||
static Future<List<MeshMapNode>> fetchNodes({
|
static Future<List<MeshMapNode>> fetchNodes({
|
||||||
bool forceRefresh = false,
|
bool forceRefresh = false,
|
||||||
Duration cacheTtl = _cacheTtl,
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
bool allowNetwork = true,
|
||||||
|
http.Client? client,
|
||||||
}) async {
|
}) async {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
if (!forceRefresh &&
|
if (!forceRefresh &&
|
||||||
_cachedNodes != null &&
|
cached.isNotEmpty &&
|
||||||
_cachedAt != null &&
|
_cachedAt != null &&
|
||||||
now.difference(_cachedAt!) < cacheTtl) {
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
return _cachedNodes!;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
final response = await http
|
if (!allowNetwork) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await (client ?? http.Client())
|
||||||
.get(Uri.parse(_nodesEndpoint))
|
.get(Uri.parse(_nodesEndpoint))
|
||||||
.timeout(traceTimeout);
|
.timeout(traceTimeout);
|
||||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
@@ -64,13 +85,134 @@ class MeshMapNodesService {
|
|||||||
final nodes = nodesRaw
|
final nodes = nodesRaw
|
||||||
.whereType<Map<String, dynamic>>()
|
.whereType<Map<String, dynamic>>()
|
||||||
.map(MeshMapNode.fromJson)
|
.map(MeshMapNode.fromJson)
|
||||||
.where(
|
.where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates)
|
||||||
(n) => n.publicKey.isNotEmpty && n.latitude != 0 && n.longitude != 0,
|
|
||||||
)
|
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
_cachedNodes = nodes;
|
await _storeCache(nodes, cachedAt: now);
|
||||||
_cachedAt = now;
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<MeshMapNode>> loadCachedNodes({
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_cachedNodes != null &&
|
||||||
|
_cachedAt != null &&
|
||||||
|
now.difference(_cachedAt!) < cacheTtl) {
|
||||||
|
return _cachedNodes!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||||
|
final cachedJson = prefs.getString(_cacheKey);
|
||||||
|
if (cachedAtMs == null || cachedJson == null) {
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = null;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||||
|
if (now.difference(cachedAt) >= cacheTtl) {
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(cachedJson) as List<dynamic>;
|
||||||
|
final nodes = decoded
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(MeshMapNode.fromJson)
|
||||||
|
.where((n) => n.publicKey.isNotEmpty && n.hasValidCoordinates)
|
||||||
|
.toList();
|
||||||
|
_cachedNodes = nodes;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> hasFreshCache({Duration cacheTtl = _cacheTtl}) async {
|
||||||
|
final nodes = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
|
return nodes.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> syncInBackgroundIfStale({
|
||||||
|
Duration cacheTtl = _cacheTtl,
|
||||||
|
http.Client? client,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_cachedAt != null && now.difference(_cachedAt!) < cacheTtl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final cached = await loadCachedNodes(cacheTtl: cacheTtl);
|
||||||
|
if (cached.isNotEmpty && _cachedAt != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_ongoingSync != null) {
|
||||||
|
return _ongoingSync!;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ongoingSync = () async {
|
||||||
|
try {
|
||||||
|
await fetchNodes(
|
||||||
|
forceRefresh: true,
|
||||||
|
cacheTtl: cacheTtl,
|
||||||
|
allowNetwork: true,
|
||||||
|
client: client,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// Background refresh is best-effort only.
|
||||||
|
} finally {
|
||||||
|
_ongoingSync = null;
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
|
||||||
|
return _ongoingSync!;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clearCache() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_cacheKey);
|
||||||
|
await prefs.remove(_cacheTimestampKey);
|
||||||
|
_cachedNodes = null;
|
||||||
|
_cachedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<DateTime?> cachedAt() async {
|
||||||
|
if (_cachedAt != null) return _cachedAt;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final cachedAtMs = prefs.getInt(_cacheTimestampKey);
|
||||||
|
if (cachedAtMs == null) return null;
|
||||||
|
_cachedAt = DateTime.fromMillisecondsSinceEpoch(cachedAtMs);
|
||||||
|
return _cachedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isRepeater(MeshMapNode node) => node.type == repeaterType;
|
||||||
|
|
||||||
|
static Future<void> _storeCache(
|
||||||
|
List<MeshMapNode> nodes, {
|
||||||
|
required DateTime cachedAt,
|
||||||
|
}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
_cacheKey,
|
||||||
|
jsonEncode(
|
||||||
|
nodes
|
||||||
|
.map(
|
||||||
|
(node) => {
|
||||||
|
'type': node.type,
|
||||||
|
'name': node.name,
|
||||||
|
'public_key': node.publicKey,
|
||||||
|
'latitude': node.latitude,
|
||||||
|
'longitude': node.longitude,
|
||||||
|
'updated_at': node.updatedAtMs,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await prefs.setInt(_cacheTimestampKey, cachedAt.millisecondsSinceEpoch);
|
||||||
|
_cachedNodes = nodes;
|
||||||
|
_cachedAt = cachedAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import '../models/message_contact_location.dart';
|
|||||||
import '../models/message_reception_details.dart';
|
import '../models/message_reception_details.dart';
|
||||||
import '../models/message_transfer_details.dart';
|
import '../models/message_transfer_details.dart';
|
||||||
import '../models/message_route_metadata.dart';
|
import '../models/message_route_metadata.dart';
|
||||||
|
import 'profiles_feature_service.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
/// Service for persisting messages to local storage
|
/// Service for persisting messages to local storage
|
||||||
class MessageStorageService {
|
class MessageStorageService {
|
||||||
static const String _messagesKey = 'stored_messages';
|
static const String _messagesKey = 'stored_messages';
|
||||||
|
static const String _removedSarMarkerIdsKey = 'removed_sar_marker_ids';
|
||||||
static const String _messageContactLocationsKey =
|
static const String _messageContactLocationsKey =
|
||||||
'stored_message_contact_locations';
|
'stored_message_contact_locations';
|
||||||
static const String _messageReceptionDetailsKey =
|
static const String _messageReceptionDetailsKey =
|
||||||
@@ -19,8 +21,14 @@ class MessageStorageService {
|
|||||||
'stored_message_transfer_details';
|
'stored_message_transfer_details';
|
||||||
static const String _messageRouteMetadataKey =
|
static const String _messageRouteMetadataKey =
|
||||||
'stored_message_route_metadata';
|
'stored_message_route_metadata';
|
||||||
|
static const String _embeddedReceptionDetailsKey = 'storedReceptionDetails';
|
||||||
|
static const String _legacyPathBytesKey = 'storedPathBytes';
|
||||||
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
static const int _maxStoredMessages = 1000; // Store up to 1000 messages
|
||||||
|
|
||||||
|
String _key(String baseKey, {String? namespace}) {
|
||||||
|
return ProfileStorageScope.scopedKey(baseKey, namespace: namespace);
|
||||||
|
}
|
||||||
|
|
||||||
/// Save messages to persistent storage
|
/// Save messages to persistent storage
|
||||||
Future<void> saveMessages(
|
Future<void> saveMessages(
|
||||||
List<Message> messages, {
|
List<Message> messages, {
|
||||||
@@ -28,12 +36,21 @@ class MessageStorageService {
|
|||||||
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
Map<String, MessageReceptionDetails> messageReceptionDetails = const {},
|
||||||
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
Map<String, MessageTransferDetails> messageTransferDetails = const {},
|
||||||
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
|
Map<String, MessageRouteMetadata> messageRouteMetadata = const {},
|
||||||
|
String? namespace,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
// Convert messages to JSON
|
// Convert messages to JSON and embed path bytes as a fallback so they
|
||||||
final jsonList = messages.map((msg) => _messageToJson(msg)).toList();
|
// survive restore even if the sidecar reception-details entry is absent.
|
||||||
|
final jsonList = messages
|
||||||
|
.map(
|
||||||
|
(msg) => _messageToJson(
|
||||||
|
msg,
|
||||||
|
receptionDetails: messageReceptionDetails[msg.id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
// Limit to max stored messages (keep most recent)
|
// Limit to max stored messages (keep most recent)
|
||||||
final limitedList = jsonList.length > _maxStoredMessages
|
final limitedList = jsonList.length > _maxStoredMessages
|
||||||
@@ -41,7 +58,10 @@ class MessageStorageService {
|
|||||||
: jsonList;
|
: jsonList;
|
||||||
|
|
||||||
final jsonString = jsonEncode(limitedList);
|
final jsonString = jsonEncode(limitedList);
|
||||||
await prefs.setString(_messagesKey, jsonString);
|
await prefs.setString(
|
||||||
|
_key(_messagesKey, namespace: namespace),
|
||||||
|
jsonString,
|
||||||
|
);
|
||||||
final retainedMessageIds = limitedList
|
final retainedMessageIds = limitedList
|
||||||
.map((entry) => entry['id'] as String)
|
.map((entry) => entry['id'] as String)
|
||||||
.toSet();
|
.toSet();
|
||||||
@@ -70,19 +90,19 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
await prefs.setString(
|
await prefs.setString(
|
||||||
_messageContactLocationsKey,
|
_key(_messageContactLocationsKey, namespace: namespace),
|
||||||
jsonEncode(locationJson),
|
jsonEncode(locationJson),
|
||||||
);
|
);
|
||||||
await prefs.setString(
|
await prefs.setString(
|
||||||
_messageReceptionDetailsKey,
|
_key(_messageReceptionDetailsKey, namespace: namespace),
|
||||||
jsonEncode(receptionJson),
|
jsonEncode(receptionJson),
|
||||||
);
|
);
|
||||||
await prefs.setString(
|
await prefs.setString(
|
||||||
_messageTransferDetailsKey,
|
_key(_messageTransferDetailsKey, namespace: namespace),
|
||||||
jsonEncode(transferJson),
|
jsonEncode(transferJson),
|
||||||
);
|
);
|
||||||
await prefs.setString(
|
await prefs.setString(
|
||||||
_messageRouteMetadataKey,
|
_key(_messageRouteMetadataKey, namespace: namespace),
|
||||||
jsonEncode(routeMetadataJson),
|
jsonEncode(routeMetadataJson),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -94,11 +114,14 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, MessageContactLocation>>
|
Future<Map<String, MessageContactLocation>> loadMessageContactLocations({
|
||||||
loadMessageContactLocations() async {
|
String? namespace,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_messageContactLocationsKey);
|
final jsonString = prefs.getString(
|
||||||
|
_key(_messageContactLocationsKey, namespace: namespace),
|
||||||
|
);
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
return const {};
|
return const {};
|
||||||
}
|
}
|
||||||
@@ -124,29 +147,48 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, MessageReceptionDetails>>
|
Future<Map<String, MessageReceptionDetails>> loadMessageReceptionDetails({
|
||||||
loadMessageReceptionDetails() async {
|
String? namespace,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_messageReceptionDetailsKey);
|
final jsonString = prefs.getString(
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
_key(_messageReceptionDetailsKey, namespace: namespace),
|
||||||
return const {};
|
);
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(jsonString);
|
|
||||||
if (decoded is! Map<String, dynamic>) {
|
|
||||||
return const {};
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = <String, MessageReceptionDetails>{};
|
final result = <String, MessageReceptionDetails>{};
|
||||||
for (final entry in decoded.entries) {
|
if (jsonString != null && jsonString.isNotEmpty) {
|
||||||
final value = entry.value;
|
final decoded = jsonDecode(jsonString);
|
||||||
if (value is! Map<String, dynamic>) continue;
|
if (decoded is Map<String, dynamic>) {
|
||||||
final snapshot = MessageReceptionDetails.fromJson(value);
|
for (final entry in decoded.entries) {
|
||||||
if (snapshot != null) {
|
final value = entry.value;
|
||||||
result[entry.key] = snapshot;
|
if (value is! Map<String, dynamic>) continue;
|
||||||
|
final snapshot = MessageReceptionDetails.fromJson(value);
|
||||||
|
if (snapshot != null) {
|
||||||
|
result[entry.key] = snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final embeddedReceptionDetails = await _loadEmbeddedReceptionDetails(
|
||||||
|
namespace: namespace,
|
||||||
|
);
|
||||||
|
embeddedReceptionDetails.forEach((messageId, snapshot) {
|
||||||
|
result.putIfAbsent(messageId, () => snapshot);
|
||||||
|
});
|
||||||
|
|
||||||
|
final fallbackPathBytes = await _loadLegacyPathBytesFromMessages(
|
||||||
|
namespace: namespace,
|
||||||
|
);
|
||||||
|
fallbackPathBytes.forEach((messageId, pathBytes) {
|
||||||
|
result.putIfAbsent(
|
||||||
|
messageId,
|
||||||
|
() => MessageReceptionDetails(
|
||||||
|
capturedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||||
|
pathBytes: pathBytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
debugPrint('❌ [MessageStorage] Error loading reception details: $e');
|
||||||
@@ -154,11 +196,14 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, MessageTransferDetails>>
|
Future<Map<String, MessageTransferDetails>> loadMessageTransferDetails({
|
||||||
loadMessageTransferDetails() async {
|
String? namespace,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_messageTransferDetailsKey);
|
final jsonString = prefs.getString(
|
||||||
|
_key(_messageTransferDetailsKey, namespace: namespace),
|
||||||
|
);
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
return const {};
|
return const {};
|
||||||
}
|
}
|
||||||
@@ -184,10 +229,14 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata() async {
|
Future<Map<String, MessageRouteMetadata>> loadMessageRouteMetadata({
|
||||||
|
String? namespace,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_messageRouteMetadataKey);
|
final jsonString = prefs.getString(
|
||||||
|
_key(_messageRouteMetadataKey, namespace: namespace),
|
||||||
|
);
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
return const {};
|
return const {};
|
||||||
}
|
}
|
||||||
@@ -211,10 +260,12 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load messages from persistent storage
|
/// Load messages from persistent storage
|
||||||
Future<List<Message>> loadMessages() async {
|
Future<List<Message>> loadMessages({String? namespace}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_messagesKey);
|
final jsonString = prefs.getString(
|
||||||
|
_key(_messagesKey, namespace: namespace),
|
||||||
|
);
|
||||||
|
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
debugPrint('ℹ️ [MessageStorage] No stored messages found');
|
debugPrint('ℹ️ [MessageStorage] No stored messages found');
|
||||||
@@ -239,25 +290,67 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all stored messages
|
/// Clear all stored messages
|
||||||
Future<void> clearMessages() async {
|
Future<void> clearMessages({String? namespace}) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.remove(_messagesKey);
|
await prefs.remove(_key(_messagesKey, namespace: namespace));
|
||||||
await prefs.remove(_messageContactLocationsKey);
|
await prefs.remove(
|
||||||
await prefs.remove(_messageReceptionDetailsKey);
|
_key(_messageContactLocationsKey, namespace: namespace),
|
||||||
await prefs.remove(_messageTransferDetailsKey);
|
);
|
||||||
await prefs.remove(_messageRouteMetadataKey);
|
await prefs.remove(
|
||||||
|
_key(_messageReceptionDetailsKey, namespace: namespace),
|
||||||
|
);
|
||||||
|
await prefs.remove(
|
||||||
|
_key(_messageTransferDetailsKey, namespace: namespace),
|
||||||
|
);
|
||||||
|
await prefs.remove(_key(_messageRouteMetadataKey, namespace: namespace));
|
||||||
|
await prefs.remove(_key(_removedSarMarkerIdsKey, namespace: namespace));
|
||||||
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
debugPrint('✅ [MessageStorage] Cleared all stored messages');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
debugPrint('❌ [MessageStorage] Error clearing messages: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get storage statistics
|
Future<Set<String>> loadRemovedSarMarkerIds({String? namespace}) async {
|
||||||
Future<Map<String, dynamic>> getStorageStats() async {
|
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final jsonString = prefs.getString(_messagesKey);
|
final ids =
|
||||||
|
prefs.getStringList(
|
||||||
|
_key(_removedSarMarkerIdsKey, namespace: namespace),
|
||||||
|
) ??
|
||||||
|
const [];
|
||||||
|
return ids.toSet();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error loading removed SAR marker IDs: $e');
|
||||||
|
return const <String>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveRemovedSarMarkerIds(
|
||||||
|
Set<String> ids, {
|
||||||
|
String? namespace,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(
|
||||||
|
_key(_removedSarMarkerIdsKey, namespace: namespace),
|
||||||
|
ids.toList()..sort(),
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'✅ [MessageStorage] Saved ${ids.length} removed SAR marker IDs',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ [MessageStorage] Error saving removed SAR marker IDs: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get storage statistics
|
||||||
|
Future<Map<String, dynamic>> getStorageStats({String? namespace}) async {
|
||||||
|
try {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(
|
||||||
|
_key(_messagesKey, namespace: namespace),
|
||||||
|
);
|
||||||
|
|
||||||
if (jsonString == null || jsonString.isEmpty) {
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
return {'messageCount': 0, 'storageSizeBytes': 0, 'storageSizeKB': 0};
|
||||||
@@ -278,7 +371,12 @@ class MessageStorageService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert Message to JSON
|
/// Convert Message to JSON
|
||||||
Map<String, dynamic> _messageToJson(Message message) {
|
Map<String, dynamic> _messageToJson(
|
||||||
|
Message message, {
|
||||||
|
MessageReceptionDetails? receptionDetails,
|
||||||
|
}) {
|
||||||
|
final persistedPathBytes =
|
||||||
|
message.pathBytes ?? _pathBytesFromSnapshot(receptionDetails);
|
||||||
return {
|
return {
|
||||||
'id': message.id,
|
'id': message.id,
|
||||||
'messageType': message.messageType.name,
|
'messageType': message.messageType.name,
|
||||||
@@ -287,12 +385,16 @@ class MessageStorageService {
|
|||||||
: null,
|
: null,
|
||||||
'channelIdx': message.channelIdx,
|
'channelIdx': message.channelIdx,
|
||||||
'pathLen': message.pathLen,
|
'pathLen': message.pathLen,
|
||||||
|
'pathBytes': persistedPathBytes?.toList(),
|
||||||
'textType': message.textType.value,
|
'textType': message.textType.value,
|
||||||
'senderTimestamp': message.senderTimestamp,
|
'senderTimestamp': message.senderTimestamp,
|
||||||
'text': message.text,
|
'text': message.text,
|
||||||
'isSarMarker': message.isSarMarker,
|
'isSarMarker': message.isSarMarker,
|
||||||
'sarGpsLat': message.sarGpsCoordinates?.latitude,
|
'sarGpsLat': message.sarGpsCoordinates?.latitude,
|
||||||
'sarGpsLon': message.sarGpsCoordinates?.longitude,
|
'sarGpsLon': message.sarGpsCoordinates?.longitude,
|
||||||
|
'sarCustomMapLat': message.sarCustomMapPoint?.latitude,
|
||||||
|
'sarCustomMapLon': message.sarCustomMapPoint?.longitude,
|
||||||
|
'sarCustomMapId': message.sarCustomMapId,
|
||||||
'sarNotes': message.sarNotes,
|
'sarNotes': message.sarNotes,
|
||||||
'sarCustomEmoji': message.sarCustomEmoji,
|
'sarCustomEmoji': message.sarCustomEmoji,
|
||||||
'sarColorIndex': message.sarColorIndex,
|
'sarColorIndex': message.sarColorIndex,
|
||||||
@@ -338,9 +440,74 @@ class MessageStorageService {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
if (receptionDetails != null)
|
||||||
|
_embeddedReceptionDetailsKey: receptionDetails.toJson(),
|
||||||
|
if (receptionDetails?.pathBytes case final pathBytes?)
|
||||||
|
_legacyPathBytesKey: List<int>.from(pathBytes),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Map<String, MessageReceptionDetails>> _loadEmbeddedReceptionDetails({
|
||||||
|
String? namespace,
|
||||||
|
}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(
|
||||||
|
_key(_messagesKey, namespace: namespace),
|
||||||
|
);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, MessageReceptionDetails>{};
|
||||||
|
for (final entry in decoded) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final messageId = entry['id'];
|
||||||
|
final embedded = entry[_embeddedReceptionDetailsKey];
|
||||||
|
if (messageId is! String || embedded is! Map<String, dynamic>) continue;
|
||||||
|
final snapshot = MessageReceptionDetails.fromJson(embedded);
|
||||||
|
if (snapshot == null) continue;
|
||||||
|
result[messageId] = snapshot;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<int>>> _loadLegacyPathBytesFromMessages({
|
||||||
|
String? namespace,
|
||||||
|
}) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final jsonString = prefs.getString(
|
||||||
|
_key(_messagesKey, namespace: namespace),
|
||||||
|
);
|
||||||
|
if (jsonString == null || jsonString.isEmpty) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(jsonString);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = <String, List<int>>{};
|
||||||
|
for (final entry in decoded) {
|
||||||
|
if (entry is! Map<String, dynamic>) continue;
|
||||||
|
final messageId = entry['id'];
|
||||||
|
final pathBytes = entry[_legacyPathBytesKey];
|
||||||
|
if (messageId is! String || pathBytes is! List) continue;
|
||||||
|
final normalized = pathBytes
|
||||||
|
.whereType<num>()
|
||||||
|
.map((b) => b.toInt())
|
||||||
|
.toList();
|
||||||
|
if (normalized.isEmpty) continue;
|
||||||
|
result[messageId] = normalized;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert JSON to Message
|
/// Convert JSON to Message
|
||||||
Message? _messageFromJson(Map<String, dynamic> json) {
|
Message? _messageFromJson(Map<String, dynamic> json) {
|
||||||
try {
|
try {
|
||||||
@@ -357,14 +524,33 @@ class MessageStorageService {
|
|||||||
: null,
|
: null,
|
||||||
channelIdx: json['channelIdx'] as int?,
|
channelIdx: json['channelIdx'] as int?,
|
||||||
pathLen: json['pathLen'] as int,
|
pathLen: json['pathLen'] as int,
|
||||||
|
pathBytes: json['pathBytes'] is List
|
||||||
|
? Uint8List.fromList(
|
||||||
|
(json['pathBytes'] as List<dynamic>)
|
||||||
|
.whereType<num>()
|
||||||
|
.map((b) => b.toInt())
|
||||||
|
.toList(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
textType: MessageTextType.fromValue(json['textType'] as int),
|
textType: MessageTextType.fromValue(json['textType'] as int),
|
||||||
senderTimestamp: json['senderTimestamp'] as int,
|
senderTimestamp: json['senderTimestamp'] as int,
|
||||||
text: json['text'] as String,
|
text: json['text'] as String,
|
||||||
isSarMarker: json['isSarMarker'] as bool? ?? false,
|
isSarMarker: json['isSarMarker'] as bool? ?? false,
|
||||||
sarGpsCoordinates:
|
sarGpsCoordinates:
|
||||||
json['sarGpsLat'] != null && json['sarGpsLon'] != null
|
json['sarGpsLat'] != null && json['sarGpsLon'] != null
|
||||||
? LatLng(json['sarGpsLat'] as double, json['sarGpsLon'] as double)
|
? LatLng(
|
||||||
|
(json['sarGpsLat'] as num).toDouble(),
|
||||||
|
(json['sarGpsLon'] as num).toDouble(),
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
|
sarCustomMapPoint:
|
||||||
|
json['sarCustomMapLat'] != null && json['sarCustomMapLon'] != null
|
||||||
|
? LatLng(
|
||||||
|
(json['sarCustomMapLat'] as num).toDouble(),
|
||||||
|
(json['sarCustomMapLon'] as num).toDouble(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
sarCustomMapId: json['sarCustomMapId'] as String?,
|
||||||
sarNotes: json['sarNotes'] as String?,
|
sarNotes: json['sarNotes'] as String?,
|
||||||
sarCustomEmoji: json['sarCustomEmoji'] as String?,
|
sarCustomEmoji: json['sarCustomEmoji'] as String?,
|
||||||
sarColorIndex: json['sarColorIndex'] as int?,
|
sarColorIndex: json['sarColorIndex'] as int?,
|
||||||
@@ -454,4 +640,12 @@ class MessageStorageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Uint8List? _pathBytesFromSnapshot(MessageReceptionDetails? receptionDetails) {
|
||||||
|
final pathBytes = receptionDetails?.pathBytes;
|
||||||
|
if (pathBytes == null || pathBytes.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Uint8List.fromList(pathBytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'profiles_feature_service.dart';
|
||||||
|
|
||||||
class MessagingRoutePreferences {
|
class MessagingRoutePreferences {
|
||||||
static const bool defaultAutoRouteRotationEnabled = false;
|
static const bool defaultAutoRouteRotationEnabled = false;
|
||||||
@@ -14,33 +15,49 @@ class MessagingRoutePreferences {
|
|||||||
|
|
||||||
static Future<bool> getAutoRouteRotationEnabled() async {
|
static Future<bool> getAutoRouteRotationEnabled() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getBool(_autoRouteRotationKey) ??
|
return prefs.getBool(
|
||||||
|
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
|
||||||
|
) ??
|
||||||
defaultAutoRouteRotationEnabled;
|
defaultAutoRouteRotationEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
|
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_autoRouteRotationKey, enabled);
|
await prefs.setBool(
|
||||||
|
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> getClearPathOnMaxRetry() async {
|
static Future<bool> getClearPathOnMaxRetry() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getBool(_clearPathOnMaxRetryKey) ?? defaultClearPathOnMaxRetry;
|
return prefs.getBool(
|
||||||
|
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
|
||||||
|
) ??
|
||||||
|
defaultClearPathOnMaxRetry;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
|
static Future<void> setClearPathOnMaxRetry(bool enabled) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_clearPathOnMaxRetryKey, enabled);
|
await prefs.setBool(
|
||||||
|
ProfileStorageScope.scopedKey(_clearPathOnMaxRetryKey),
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> getNearestRelayFallbackEnabled() async {
|
static Future<bool> getNearestRelayFallbackEnabled() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
return prefs.getBool(_nearestRelayFallbackKey) ??
|
return prefs.getBool(
|
||||||
|
ProfileStorageScope.scopedKey(_nearestRelayFallbackKey),
|
||||||
|
) ??
|
||||||
defaultNearestRelayFallbackEnabled;
|
defaultNearestRelayFallbackEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
|
static Future<void> setNearestRelayFallbackEnabled(bool enabled) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_nearestRelayFallbackKey, enabled);
|
await prefs.setBool(
|
||||||
|
ProfileStorageScope.scopedKey(_nearestRelayFallbackKey),
|
||||||
|
enabled,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user