mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-08-08 08:47:32 +10:00
Compare commits
475 Commits
Alpha3
...
dev-privacy
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ad9263cc4 | |||
| 3f780ac667 | |||
| 53caec3e14 | |||
| 3c440ca3d4 | |||
| 8797d8ffde | |||
| faba120823 | |||
| be690c8194 | |||
| 64d75dde45 | |||
| 9199aab7f7 | |||
| 60e8ee0130 | |||
| 6dfb7a4b69 | |||
| 28a423e0a8 | |||
| 3593cfa843 | |||
| dc85e7a41c | |||
| 9265daaf16 | |||
| 4b744184c2 | |||
| 64698e0be6 | |||
| 3dd9037be3 | |||
| 566e3aadf8 | |||
| 06a906f4f7 | |||
| 054a84031e | |||
| fffcff3b74 | |||
| b336aedbc5 | |||
| 2ee2358ecc | |||
| 86e9b7fe01 | |||
| 24fa78741b | |||
| 79a45c527b | |||
| 8b280b37be | |||
| fa4da979af | |||
| 91608ff09e | |||
| 71f59d23df | |||
| e90742be25 | |||
| db935a7454 | |||
| 1ad5db27ca | |||
| 81758adc61 | |||
| c81791cf1e | |||
| 1fba5312a2 | |||
| 2f770bbd53 | |||
| 9db79e9d40 | |||
| 1913a5aa11 | |||
| 929c1c3d28 | |||
| 7a2bb20bf7 | |||
| a1b77bb29b | |||
| 4eecfc92dc | |||
| 90c8cf5f3e | |||
| 06fa176367 | |||
| e4285774a0 | |||
| b2da695102 | |||
| e1327a93c7 | |||
| 421bc71bb7 | |||
| fef73b7b62 | |||
| 84ec139ce6 | |||
| b748b96237 | |||
| c2671ac2ae | |||
| 8238b6197f | |||
| 435ba89982 | |||
| 0565cee461 | |||
| ab2b509d6a | |||
| eba95af31f | |||
| 04c016cfe1 | |||
| ea2354712d | |||
| 7a0b8aad3d | |||
| bd34bb5e88 | |||
| 81548fdc21 | |||
| b2770ef028 | |||
| 7c479f9121 | |||
| 1f2dfc555b | |||
| 8eb6f32fef | |||
| d96cd34771 | |||
| fb58a3262c | |||
| f584c4fba0 | |||
| b5b930646f | |||
| 3452bdae8c | |||
| 25fc9454a8 | |||
| 524558c511 | |||
| 367e47bb1e | |||
| 21ff765e41 | |||
| 38d40ca0a4 | |||
| 5b4535d5dc | |||
| f9b6299620 | |||
| 7cb84dbf6f | |||
| 44c0670dae | |||
| 74da9e82b5 | |||
| 63583dadda | |||
| 32632669c3 | |||
| 3c0c0d1dea | |||
| e6c9a3fea7 | |||
| f5154b0033 | |||
| 4c7ee3b3b0 | |||
| c2f544eeba | |||
| 98cdac4309 | |||
| d6d11eaad2 | |||
| 3cef9e81b6 | |||
| 5216e00807 | |||
| a0feb129e1 | |||
| f39a22668e | |||
| 781090243c | |||
| ca5784f3f8 | |||
| dcad5c586d | |||
| 4b24506310 | |||
| 47c4e0fb82 | |||
| c041e05972 | |||
| 612612795a | |||
| 3cec3dc233 | |||
| 3542adad1d | |||
| 115689ad95 | |||
| 9a0572e8e4 | |||
| 2d1160d992 | |||
| ee3af52c0f | |||
| 98f7c3b088 | |||
| f462815775 | |||
| 5f4333398e | |||
| c23a1da430 | |||
| 22a53439b1 | |||
| 7d8e049745 | |||
| 3502559fae | |||
| e125318137 | |||
| d53465d13b | |||
| a0efbbe4bd | |||
| bd5db9a9d5 | |||
| 79b17b53a0 | |||
| 647fe1523e | |||
| b7d5ee5754 | |||
| 38856c67e5 | |||
| 6bd3c17cdf | |||
| 6d0712c450 | |||
| ddeb1edc2e | |||
| 8d73602509 | |||
| fcab69f9f0 | |||
| d2640e1294 | |||
| b02225c02e | |||
| 128e99e3e7 | |||
| 12bf46bba1 | |||
| 92d8e7cd0b | |||
| 75610695c2 | |||
| 57ea30cae9 | |||
| e139383335 | |||
| 64428294c9 | |||
| e7a8c36bc4 | |||
| 2a62390903 | |||
| 75d25f6312 | |||
| 2a3119544c | |||
| fb41a5bf10 | |||
| d88786bb0f | |||
| e3148dd449 | |||
| 96371c03ae | |||
| cac65face6 | |||
| bdb1eb6b42 | |||
| f2ccec2926 | |||
| 31671958d5 | |||
| ea379ce50b | |||
| 50af2e0bc9 | |||
| d5ac84430c | |||
| 190fd3b353 | |||
| a2d1cb2a99 | |||
| 83386a8cde | |||
| acc0fff2dc | |||
| a26055c93f | |||
| 5a70ed48cf | |||
| a777236cd9 | |||
| a42cf77a70 | |||
| 31db565ebf | |||
| 515b9c1f29 | |||
| ea1d728d4f | |||
| 86bde1d178 | |||
| de63733bb9 | |||
| c880c2d107 | |||
| 2a7cc28a3a | |||
| 8a16024642 | |||
| 0f17e2382c | |||
| 6065059241 | |||
| faefef14ff | |||
| ddc87f3a27 | |||
| 2188b49726 | |||
| 1a9b7b0d55 | |||
| 74e29a6c0f | |||
| 7740698cde | |||
| 972ae809e3 | |||
| deb46553f3 | |||
| 58fc55df13 | |||
| ea2f35ec2e | |||
| e2585c0992 | |||
| 78f1a7b28e | |||
| 0121b5f649 | |||
| ec14870aed | |||
| c0516a475d | |||
| b998186430 | |||
| 16b2c24983 | |||
| c8ff0cc943 | |||
| 64bf307d09 | |||
| 88f8066ed3 | |||
| c8f93f9902 | |||
| c34be44950 | |||
| bf5fadd15e | |||
| 3730b2a6c2 | |||
| 173fdf7168 | |||
| 549fc62632 | |||
| 53d073d8f2 | |||
| 7465e81996 | |||
| 677b25908a | |||
| fc55fb98ce | |||
| 2bdd9d35cc | |||
| 1f816f7e08 | |||
| bd27c90216 | |||
| 9bcb8b9ca6 | |||
| aaf79c90c9 | |||
| 08edd2696e | |||
| 0f2d18d6fa | |||
| 298951f8bc | |||
| f3db63ceea | |||
| 47044ae14e | |||
| f4dd76a459 | |||
| ab76a52d71 | |||
| 332bb5ef3a | |||
| 81a423d096 | |||
| 700e85b13d | |||
| 9a27953a6e | |||
| abde4a5e46 | |||
| 6e1cb0482f | |||
| c28b38a233 | |||
| 722caf774e | |||
| 4975b5366e | |||
| d269e181c3 | |||
| 35498c1b90 | |||
| bf4f52a4e3 | |||
| c284e571b0 | |||
| a1ee0789a6 | |||
| 3ca53e967c | |||
| 096e0a4184 | |||
| 40ac95e8e6 | |||
| 377f1df445 | |||
| 9865a03c53 | |||
| a5555bd606 | |||
| 1b4d31a36e | |||
| 8e07440114 | |||
| 71129bdf4d | |||
| ab05cf8b3e | |||
| 452e5337f0 | |||
| 6ac987e7cf | |||
| 5522f9a236 | |||
| b4f79c1aae | |||
| b08defcff4 | |||
| 5676cbd84e | |||
| cf8f01128b | |||
| b5e47ce44f | |||
| 7b2f75047c | |||
| 6d63e49938 | |||
| c7b33f1d1b | |||
| 7288f11c88 | |||
| 2306269384 | |||
| 41ff2353a4 | |||
| b3ad54f296 | |||
| 7cb4c5a334 | |||
| bb8ad70cb9 | |||
| 8fe4129204 | |||
| 2feff809ff | |||
| 51d70ce086 | |||
| b05b62eeee | |||
| 061b715694 | |||
| f38b8b0319 | |||
| 304c389669 | |||
| 7acfe47fd7 | |||
| f4b18d97a1 | |||
| d2b693e5ce | |||
| ba2763a3f6 | |||
| 0c4910e149 | |||
| 4bf2519559 | |||
| 19edeab9d5 | |||
| 0e81d75cce | |||
| 9437846127 | |||
| 50ab46ed40 | |||
| dc193be8ed | |||
| 8a804a3706 | |||
| 1dc90d0e89 | |||
| 5f2312e086 | |||
| 4239fb11ed | |||
| 5fae2e5f73 | |||
| 947fafbbb7 | |||
| a9fbf8c7f5 | |||
| 72f0aa7208 | |||
| f87d4896ab | |||
| 9250dfec31 | |||
| 37db955ab2 | |||
| 739d9475c0 | |||
| b526175be4 | |||
| 73081862ad | |||
| fac062a100 | |||
| ef6bd78632 | |||
| 01c8390989 | |||
| c05f813d65 | |||
| c52b19b09f | |||
| 6a666839b6 | |||
| bc77f7e287 | |||
| 9332d8126f | |||
| 9ce00556ec | |||
| 4995f5f380 | |||
| 4e6e7b6061 | |||
| aa350aa4ae | |||
| dfd38b19e9 | |||
| 4afab3f629 | |||
| 67816130ac | |||
| d573f0c312 | |||
| 5b699cd624 | |||
| a4d3d248a5 | |||
| 2a3f2b3a24 | |||
| 675083fa01 | |||
| 5fc4b80b16 | |||
| 84a32c1e67 | |||
| 607583060a | |||
| 71cf556b61 | |||
| c26174ad18 | |||
| 04021a39a1 | |||
| fe23e9f7a0 | |||
| d7ec1876af | |||
| 87a2807f5b | |||
| daca42701c | |||
| ea43cf17eb | |||
| 8ef6e2c656 | |||
| 24de98d5ee | |||
| 0fd841b5b5 | |||
| c365b7889b | |||
| 2db30ace6a | |||
| 0d8801fa75 | |||
| bcae6ac19f | |||
| 2f4b230b31 | |||
| 98e0b05e73 | |||
| 2a909e6081 | |||
| d1009d3c20 | |||
| 91b1696bc5 | |||
| 978ea4790d | |||
| 8b1228bf8d | |||
| ddee76ced2 | |||
| 6a3c59fa2c | |||
| a54cc78691 | |||
| 05fb5a13fa | |||
| c320378be1 | |||
| b3645481c7 | |||
| 589707aa13 | |||
| 6070802213 | |||
| 2525b9425b | |||
| b786c90514 | |||
| a35590a407 | |||
| 8d15f7cef6 | |||
| e449f5e1d5 | |||
| b34d684e67 | |||
| 488a286701 | |||
| c742d98fbb | |||
| 1d4c9ad9bd | |||
| 818f514702 | |||
| be54419e5b | |||
| 00eb1a68a6 | |||
| 79ffc21bd6 | |||
| 0374f4f5da | |||
| 4650584f9b | |||
| 8d8b938878 | |||
| e3a0bd3b13 | |||
| 4f83d87f8c | |||
| 6d7d51f0a4 | |||
| 33680f0cb9 | |||
| 5115d8bbe3 | |||
| d30e7c4e2c | |||
| 8470171e88 | |||
| ede3142d40 | |||
| 6712088fcd | |||
| 7b519854d7 | |||
| 90ce46392a | |||
| d61ec217fc | |||
| 3ac81a5448 | |||
| 7004067839 | |||
| 935b7b07eb | |||
| cdacc54421 | |||
| bf8f002d55 | |||
| 998ff50495 | |||
| 92d2b224e7 | |||
| 34a6b5d895 | |||
| c953a1a798 | |||
| 42115bf200 | |||
| d0c8fab6fb | |||
| eeb8ff34e8 | |||
| 641307a316 | |||
| c37abb63e3 | |||
| 898ef1c11c | |||
| 749f9d4dfd | |||
| 9c1b5899fb | |||
| cacb9bc677 | |||
| 0ebd688787 | |||
| bb18038f60 | |||
| fcf741b20a | |||
| 88aa104ae5 | |||
| 90f90ad7cf | |||
| 8b0bdd9a46 | |||
| 45d914de57 | |||
| 2c49534955 | |||
| c56cf9c3ed | |||
| fee4cd13be | |||
| a53d5ccfb6 | |||
| e5d06b1c7e | |||
| e95a55e4f0 | |||
| 422ca941c2 | |||
| 3098d860e9 | |||
| f0d34f7503 | |||
| daa0c3f9c3 | |||
| 09e1cd2b8d | |||
| fa514533eb | |||
| 75b8b8af70 | |||
| 115667a27c | |||
| cfb51d96ff | |||
| 75356fe20d | |||
| 2089613696 | |||
| c43df67fac | |||
| e2b9b58d7d | |||
| d6794bc8d7 | |||
| 72216e2cf7 | |||
| 2a2275ec31 | |||
| dff037535d | |||
| 297e609b3e | |||
| 20171c491f | |||
| cc43f4d198 | |||
| 537384ea5b | |||
| a0be63b2e7 | |||
| 1cc887e5bb | |||
| 26d9029538 | |||
| 30bcbedf5e | |||
| 4003519deb | |||
| 3fdd8f5eaf | |||
| f4ec732de8 | |||
| f790604d23 | |||
| 8e3b563aba | |||
| ee3b0a3126 | |||
| 31d633ee0b | |||
| c269365d81 | |||
| 9a9f59e53f | |||
| 9cb667fad0 | |||
| 3fef594fe5 | |||
| 8387304d2a | |||
| 2acba9eb84 | |||
| 30ba1799e1 | |||
| 13f9c5058a | |||
| 98fc2d6e0a | |||
| 2becbb342c | |||
| 5b2d5a494c | |||
| 153736d36e | |||
| 6c8a149e1b | |||
| b41ccee4f9 | |||
| 04a713bb76 | |||
| 714aecd7e6 | |||
| 2e1a5e0fbf | |||
| 1f0b7d8d7b | |||
| dffea23ce2 | |||
| e0a8fb7ec0 | |||
| 06fc08c41f | |||
| c22bfed680 | |||
| 316c76e5b4 | |||
| 4b215ad574 | |||
| 09e60cebd9 | |||
| 6782347cf4 | |||
| 1726119c3e | |||
| 988806dccd | |||
| 14ff8250c0 | |||
| 2a04ebb8b6 | |||
| a14462978d | |||
| df7fb45683 | |||
| f01eff07ff | |||
| 7cc7183e0c | |||
| a6b2756d0d | |||
| 614f3d4601 | |||
| 7c33647119 | |||
| fde8b686f5 | |||
| 9bc3a27b53 | |||
| a8f387b0da | |||
| dd1a73c247 | |||
| e36f6b7eb9 | |||
| fcef82be63 | |||
| 6ddb8f1a3d | |||
| 7a22223756 |
@@ -0,0 +1,78 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
android:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "17"
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-${{ hashFiles('android/gradle/wrapper/gradle-wrapper.properties', 'android/build.gradle', 'android/settings.gradle', 'android/app/build.gradle', 'pubspec.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-gradle-
|
||||
- run: flutter pub get
|
||||
- run: flutter build apk --release --no-pub
|
||||
|
||||
ios:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- run: flutter build ios --release --no-codesign --no-pub
|
||||
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- name: Install Linux build deps
|
||||
run: sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev
|
||||
- run: flutter pub get
|
||||
- run: flutter build linux --release --no-pub
|
||||
|
||||
macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- run: flutter build macos --release --no-pub
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: "stable"
|
||||
cache: true
|
||||
- run: flutter pub get
|
||||
- run: flutter build web --release --no-pub
|
||||
@@ -0,0 +1,38 @@
|
||||
name: Deploy to Cloudflare Workers
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: 'stable'
|
||||
# Match local development version which provides Dart 3.11.0
|
||||
flutter-version: '3.41.2'
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Get dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Build Web
|
||||
run: bun run build
|
||||
|
||||
- name: Deploy to Cloudflare
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: deploy
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Flutter and Dart
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Flutter
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
channel: stable
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Analyze code
|
||||
run: flutter analyze --fatal-infos --fatal-warnings
|
||||
|
||||
- name: Verify formatting
|
||||
run: dart format --output=none --set-exit-if-changed .
|
||||
|
||||
- name: Run tests
|
||||
run: flutter test -r github
|
||||
@@ -30,6 +30,7 @@ migrate_working_dir/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
pubspec.lock
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
@@ -65,11 +66,13 @@ secrets.dart
|
||||
**/ios/Flutter/Flutter.podspec
|
||||
|
||||
# Android
|
||||
.gradle/
|
||||
**/android/.gradle/
|
||||
**/android/captures/
|
||||
**/android/local.properties
|
||||
**/android/.externalNativeBuild/
|
||||
*.jks
|
||||
key.properties
|
||||
keystore.properties
|
||||
|
||||
# Generated files
|
||||
@@ -80,3 +83,6 @@ keystore.properties
|
||||
# IDE
|
||||
.vscode/launch.json
|
||||
.vscode/settings.json
|
||||
|
||||
# Cloudflare Wrangler
|
||||
.wrangler
|
||||
@@ -0,0 +1 @@
|
||||
4.0.0
|
||||
@@ -0,0 +1 @@
|
||||
6.2.4
|
||||
@@ -6,6 +6,10 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices.
|
||||
|
||||
MeshCore Open is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities.
|
||||
|
||||
<a href="http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/zjs81/meshcore-open">
|
||||
<img src="assets/badges/badge_obtainium.png" height="80" align="center" alt="Get it on Obtainium"/>
|
||||
</a>
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
@@ -21,6 +25,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
|
||||
- **Direct Messaging**: Private encrypted conversations with individual contacts
|
||||
- **Public Channels**: Broadcast messages to channel subscribers on the mesh network
|
||||
- **Contact Management**: Organize contacts, track last seen times, and manage conversation history
|
||||
@@ -29,6 +34,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
- **Message Replies**: Thread conversations with inline reply functionality
|
||||
|
||||
### Mesh Network
|
||||
|
||||
- **Path Visualization**: View routing paths and signal quality for each contact
|
||||
- **Route Management**: Manual path overriding and automatic route rotation
|
||||
- **Signal Metrics**: Real-time SNR (Signal-to-Noise Ratio) tracking
|
||||
@@ -36,6 +42,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
- **Repeater Support**: Connect to and manage repeater nodes for extended range
|
||||
|
||||
### Map & Location
|
||||
|
||||
- **Live Map View**: Real-time visualization of mesh network nodes on an interactive map
|
||||
- **Node Filtering**: Filter by node type (chat, repeater, sensor) and time range
|
||||
- **Location Sharing**: Share GPS coordinates and custom markers with contacts
|
||||
@@ -43,12 +50,14 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
- **MGRS Coordinates**: Support for Military Grid Reference System coordinate format
|
||||
|
||||
### Device Management
|
||||
- **BLE Connection**: Scan and connect to MeshCore devices via Bluetooth
|
||||
|
||||
- **BLE, USB, TCP Connection**: Scan and connect to MeshCore devices via Bluetooth, USB or TCP
|
||||
- **Device Settings**: Configure radio parameters, power settings, and network options
|
||||
- **Battery Monitoring**: Real-time battery status with chemistry-specific voltage curves
|
||||
- **Firmware Updates**: Over-the-air firmware updates via BLE (coming soon)
|
||||
|
||||
### Repeater Hub
|
||||
|
||||
- **CLI Access**: Full command-line interface to repeater nodes
|
||||
- **Settings Management**: Configure repeater behavior, power limits, and network settings
|
||||
- **Statistics Dashboard**: View repeater traffic, connected clients, and system health
|
||||
@@ -57,6 +66,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
## Technical Details
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Framework**: Flutter 3.38.5 / Dart 3.10.4
|
||||
- **State Management**: Provider pattern with ChangeNotifier
|
||||
- **BLE Protocol**: Nordic UART Service (NUS) over Bluetooth Low Energy
|
||||
@@ -64,11 +74,20 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
- **Encryption**: End-to-end encryption for private messages using the MeshCore protocol
|
||||
|
||||
### Platform Support
|
||||
- ✅ **Android**: Full support (API 21+)
|
||||
- ✅ **iOS**: Full support (iOS 12+)
|
||||
- 🚧 **Desktop**: Limited support (macOS/Linux/Windows)
|
||||
|
||||
| Feature | Android (API 21+) | iOS (12+) | Linux | Windows | macOS | Web |
|
||||
|--------------------|:-----------------:|:---------:|:-----:|:-------:|:-----:|:---------------------------------:|
|
||||
| BLE companion | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| USB companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ✅ |
|
||||
| TCP companion | ✅ | 🚧 | ✅ | ✅ | ✅ | ❌<br>(requires websocket bridge) |
|
||||
| Core Functionality | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Mesh Network | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Map & Location | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Device Management | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
| Repeater Hub | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
### Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| flutter_blue_plus | Bluetooth Low Energy communication |
|
||||
@@ -84,6 +103,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Flutter SDK 3.38.5 or later
|
||||
- Android Studio / Xcode (for mobile development)
|
||||
- A MeshCore-compatible LoRa device
|
||||
@@ -91,17 +111,20 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zjs81/meshcore-open.git
|
||||
cd meshcore-open
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
3. **Run the app**
|
||||
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
@@ -109,11 +132,13 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
### Building for Release
|
||||
|
||||
**Android APK:**
|
||||
|
||||
```bash
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
**iOS:**
|
||||
|
||||
```bash
|
||||
flutter build ios --release
|
||||
```
|
||||
@@ -152,25 +177,31 @@ lib/
|
||||
## BLE Protocol
|
||||
|
||||
### Nordic UART Service (NUS)
|
||||
|
||||
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
- **RX Characteristic**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (Write to device)
|
||||
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
|
||||
|
||||
### Device Discovery
|
||||
|
||||
Devices are discovered by scanning for BLE advertisements with the name prefix `MeshCore-`
|
||||
|
||||
### Message Format
|
||||
|
||||
Messages are transmitted as binary frames using a custom protocol optimized for LoRa transmission. See `meshcore_protocol.dart` for frame structure definitions.
|
||||
|
||||
## Configuration
|
||||
|
||||
### App Settings
|
||||
|
||||
- **Theme**: System default, light, or dark mode
|
||||
- **Language**: Use one of 15 languages (English, Chinese, French, Spanish, Portuguese, German, Dutch, Polish, Swedish, Italian, Slovak, Slovene, Bulgarian, Russian, Ukrainian)
|
||||
- **Notifications**: Configurable for messages, channels, and node advertisements
|
||||
- **Battery Chemistry**: Support for NMC, LiFePO4, and LiPo battery types
|
||||
- **Message Retry**: Automatic retry with configurable path clearing
|
||||
|
||||
### Device Settings
|
||||
|
||||
- **Radio Power**: Transmit power adjustment (10-30 dBm)
|
||||
- **Frequency**: LoRa frequency configuration
|
||||
- **Bandwidth**: Channel bandwidth selection
|
||||
@@ -182,22 +213,24 @@ Messages are transmitted as binary frames using a custom protocol optimized for
|
||||
This is an open-source project. Contributions are welcome!
|
||||
|
||||
### Development Guidelines
|
||||
|
||||
- Follow the Flutter style guide
|
||||
- Use Material 3 design components
|
||||
- Write clear commit messages
|
||||
- Test on both Android and iOS before submitting PRs
|
||||
|
||||
### Code Style
|
||||
|
||||
- Prefer `StatelessWidget` with `Consumer` for reactive UI
|
||||
- Use `const` constructors where possible
|
||||
- Keep functions small and focused
|
||||
- Avoid premature abstractions
|
||||
|
||||
- Run dart format on all changes before submitting
|
||||
|
||||
## Support
|
||||
|
||||
For issues, questions, or feature requests, please open an issue on GitHub:
|
||||
https://github.com/zjs81/meshcore-open/issues
|
||||
<https://github.com/zjs81/meshcore-open/issues>
|
||||
|
||||
## Donate
|
||||
|
||||
@@ -205,6 +238,11 @@ If you find MeshCore Open useful and would like to support development, you can
|
||||
|
||||
**Solana Address:** `F15YanjZj96YTBtKJYgNa8RLQLCZkx5CEwogPWkqXeoQ`
|
||||
|
||||
|
||||
**Monero Address:** `453TxnpUqjkJtXxzdjMsrgERNkBRXEGamPbpC45ENrvKAk9tH7kZbxWF82Hz66etgDZyXFPEBU2JUEqhLeJyWt9kBvTVy5m`
|
||||
|
||||
**Bitcoin Address:** `bc1qh45x28v8dslcg4v4upmqd9g0mvc3lnyffmyzr5`
|
||||
|
||||
Your support helps maintain and improve this open-source project!
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# TestFlight and App Store Deployment Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [x] Apple Developer Account ($99/year) - [developer.apple.com](https://developer.apple.com)
|
||||
- [x] Xcode installed
|
||||
- [x] Apple Transporter app installed
|
||||
- [x] App icons ready (1024x1024px)
|
||||
- [x] Bundle ID configured: `com.monitormx.meshcoreopen`
|
||||
|
||||
## Step 1: Register Bundle Identifier
|
||||
|
||||
1. Go to [Apple Developer - Identifiers](https://developer.apple.com/account/resources/identifiers/list)
|
||||
2. Click the **"+"** button
|
||||
3. Select **"App IDs"** → Continue
|
||||
4. Select **"App"** → Continue
|
||||
5. Fill in:
|
||||
- **Description**: Meshcore Open
|
||||
- **Bundle ID**: Explicit - `com.monitormx.meshcoreopen`
|
||||
- **Capabilities**: Leave defaults (or add as needed)
|
||||
6. Click **Continue** → **Register**
|
||||
|
||||
## Step 2: Create App in App Store Connect
|
||||
|
||||
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
|
||||
2. Sign in with your Apple ID
|
||||
3. Click **"My Apps"**
|
||||
4. Click the **"+"** button → **"New App"**
|
||||
5. Fill in the form:
|
||||
- **Platforms**: iOS
|
||||
- **Name**: Meshcore Open
|
||||
- **Primary Language**: English (U.S.)
|
||||
- **Bundle ID**: Select `com.monitormx.meshcoreopen` from dropdown
|
||||
- **SKU**: `meshcore-open-001` (or any unique identifier)
|
||||
- **User Access**: Full Access
|
||||
6. Click **"Create"**
|
||||
|
||||
## Step 3: Build the IPA
|
||||
|
||||
Run these commands from the project directory:
|
||||
|
||||
```bash
|
||||
# Add CocoaPods to PATH
|
||||
export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
|
||||
|
||||
# Clean previous builds
|
||||
../flutter/bin/flutter clean
|
||||
|
||||
# Build IPA for App Store
|
||||
../flutter/bin/flutter build ipa
|
||||
```
|
||||
|
||||
The IPA will be created at: `build/ios/ipa/meshcore_open.ipa`
|
||||
|
||||
## Step 4: Upload to App Store Connect via Transporter
|
||||
|
||||
1. **Open Apple Transporter**
|
||||
- Launch from Applications folder
|
||||
- Sign in with your Apple ID
|
||||
|
||||
2. **Upload the IPA**
|
||||
- Drag and drop `build/ios/ipa/meshcore_open.ipa` into Transporter
|
||||
- Click **"Deliver"**
|
||||
- Wait for upload to complete (usually 1-5 minutes)
|
||||
|
||||
3. **Processing**
|
||||
- Apple will process your build (10-30 minutes)
|
||||
- You'll receive an email when processing is complete
|
||||
|
||||
## Step 5: Configure App Store Connect Metadata
|
||||
|
||||
### App Information
|
||||
1. In App Store Connect, go to your app
|
||||
2. Fill in required information:
|
||||
- **Subtitle**: Short description (30 chars max)
|
||||
- **Privacy Policy URL**: Required for Bluetooth apps
|
||||
- **Category**: Utilities or Productivity
|
||||
- **Age Rating**: Complete questionnaire
|
||||
|
||||
### App Store Listing
|
||||
1. Go to **App Store** tab
|
||||
2. Upload **Screenshots** (required):
|
||||
- iPhone 6.7" display (1290 x 2796 pixels) - At least 1 screenshot
|
||||
- iPhone 6.5" display (1242 x 2688 pixels) - At least 1 screenshot
|
||||
- Optional: iPad screenshots
|
||||
|
||||
3. Fill in **Description**:
|
||||
```
|
||||
Meshcore Open is a Flutter client for MeshCore LoRa mesh networking devices.
|
||||
|
||||
Features:
|
||||
- BLE connectivity to MeshCore devices
|
||||
- Real-time mesh network communication
|
||||
- Map visualization with OpenStreetMap
|
||||
- Community management with QR code scanning
|
||||
- Message tracking and retry system
|
||||
|
||||
Connect to your MeshCore LoRa device and start communicating over the mesh network.
|
||||
```
|
||||
|
||||
4. **Keywords**: `lora,mesh,networking,bluetooth,communication`
|
||||
5. **Support URL**: Your GitHub or website URL
|
||||
6. **Marketing URL**: (Optional)
|
||||
|
||||
### Version Information
|
||||
1. **What's New in This Version**:
|
||||
```
|
||||
Initial release of Meshcore Open
|
||||
|
||||
- BLE device connectivity
|
||||
- Mesh network messaging
|
||||
- Map integration
|
||||
- Community features
|
||||
```
|
||||
|
||||
2. **Build**: Select the uploaded build once processing completes
|
||||
|
||||
## Step 6: TestFlight Setup
|
||||
|
||||
### Internal Testing (No Review Required)
|
||||
1. Go to **TestFlight** tab in App Store Connect
|
||||
2. Click **Internal Testing** → **"+"** to create a group
|
||||
3. Name your group (e.g., "Internal Testers")
|
||||
4. Add yourself as a tester using your email
|
||||
5. Select the build you uploaded
|
||||
6. Testers will receive an email with TestFlight invitation
|
||||
|
||||
### External Testing (Requires Beta Review)
|
||||
1. Click **External Testing** → **"+"** to create a group
|
||||
2. Add build and testers
|
||||
3. Fill in **Test Information**:
|
||||
- **What to Test**: Brief description of features
|
||||
- **Feedback Email**: Your email address
|
||||
4. Click **Submit for Review**
|
||||
5. Beta review typically takes 24-48 hours
|
||||
|
||||
## Step 7: App Store Submission
|
||||
|
||||
Once you're ready for public release:
|
||||
|
||||
1. Go to **App Store** tab
|
||||
2. Complete all required metadata (if not done)
|
||||
3. Select your build
|
||||
4. Fill in **App Review Information**:
|
||||
- **Contact Information**: Your name, phone, email
|
||||
- **Demo Account**: If app requires login
|
||||
- **Notes**: Any special instructions for reviewers
|
||||
5. Answer **Export Compliance** questions:
|
||||
- Does your app use encryption? **Yes** (uses TLS/HTTPS)
|
||||
- Is encryption registration required? **No** (standard encryption)
|
||||
6. Click **Add for Review**
|
||||
7. Review summary and click **Submit to App Review**
|
||||
|
||||
## Step 8: After Submission
|
||||
|
||||
- **App Review**: Typically 24-48 hours
|
||||
- **Common Rejection Reasons**:
|
||||
- Missing privacy policy
|
||||
- Incomplete app information
|
||||
- Crashes or bugs
|
||||
- Misleading app description
|
||||
|
||||
- **If Approved**: You can release immediately or schedule a release date
|
||||
- **If Rejected**: Address issues and resubmit
|
||||
|
||||
## Updating the App
|
||||
|
||||
When you need to release an update:
|
||||
|
||||
1. **Update version** in `pubspec.yaml`:
|
||||
```yaml
|
||||
version: 0.5.0+6 # Increment version (0.5.0) and build number (+6)
|
||||
```
|
||||
|
||||
2. **Build new IPA**:
|
||||
```bash
|
||||
export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
|
||||
../flutter/bin/flutter clean
|
||||
../flutter/bin/flutter build ipa
|
||||
```
|
||||
|
||||
3. **Upload via Transporter** (same process as above)
|
||||
|
||||
4. **Create new version** in App Store Connect:
|
||||
- Click **"+"** next to versions
|
||||
- Select version number
|
||||
- Update "What's New" text
|
||||
- Select new build
|
||||
- Submit for review
|
||||
|
||||
## macOS Build (Bonus)
|
||||
|
||||
To build for macOS:
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH"
|
||||
../flutter/bin/flutter build macos --release
|
||||
cd build/macos/Build/Products/Release
|
||||
zip -r meshcore_open-macos.zip meshcore_open.app
|
||||
```
|
||||
|
||||
Distribution:
|
||||
- Share the zip file directly
|
||||
- Users unzip and drag to Applications
|
||||
- First run: Right-click → Open (to bypass Gatekeeper)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Build Errors
|
||||
- **CocoaPods not found**: Ensure PATH includes `/opt/homebrew/lib/ruby/gems/4.0.0/bin`
|
||||
- **No signing certificate**: Configure Team in Xcode (Signing & Capabilities)
|
||||
- **Bundle ID mismatch**: Check `ios/Runner.xcodeproj/project.pbxproj`
|
||||
|
||||
### Upload Errors
|
||||
- **No profiles found**: Create app in App Store Connect first
|
||||
- **Bundle ID not registered**: Register in Apple Developer portal
|
||||
- **Authentication failed**: Use Transporter app instead of CLI
|
||||
|
||||
### TestFlight Issues
|
||||
- **Build not appearing**: Wait 10-30 minutes for processing
|
||||
- **Can't add testers**: Check you have available slots (100 internal, 10,000 external)
|
||||
- **TestFlight crashes**: Check device logs in Xcode → Devices & Simulators
|
||||
|
||||
## Important Files
|
||||
|
||||
- **iOS IPA**: `build/ios/ipa/meshcore_open.ipa`
|
||||
- **macOS App**: `build/macos/Build/Products/Release/meshcore_open.app`
|
||||
- **Bundle ID Config**: `ios/Runner.xcodeproj/project.pbxproj`
|
||||
- **Version Info**: `pubspec.yaml`
|
||||
|
||||
## Useful Links
|
||||
|
||||
- [App Store Connect](https://appstoreconnect.apple.com)
|
||||
- [Apple Developer Portal](https://developer.apple.com/account)
|
||||
- [TestFlight Documentation](https://developer.apple.com/testflight/)
|
||||
- [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/)
|
||||
- [Flutter iOS Deployment](https://docs.flutter.dev/deployment/ios)
|
||||
|
||||
## Support
|
||||
|
||||
For issues with:
|
||||
- **App Store Process**: [Apple Developer Support](https://developer.apple.com/contact/)
|
||||
- **Flutter Build Issues**: [Flutter GitHub](https://github.com/flutter/flutter/issues)
|
||||
- **Meshcore Open App**: [GitHub Issues](https://github.com/wel97459/meshcore-open/issues)
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
@@ -5,19 +7,25 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.meshcore.meshcore_open"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_11.toString()
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
@@ -40,11 +48,25 @@ android {
|
||||
// }
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
val storeFilePath = keystoreProperties["storeFile"] as String?
|
||||
if (storeFilePath != null) {
|
||||
storeFile = file(storeFilePath)
|
||||
storePassword = keystoreProperties["storePassword"] as String?
|
||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,5 +83,5 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<!-- Camera permission for QR code scanning -->
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
|
||||
|
||||
<application
|
||||
android:label="meshcore_open"
|
||||
@@ -64,5 +68,14 @@
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
<!-- URL launcher intents for opening links -->
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="http"/>
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<data android:scheme="https"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
package com.meshcore.meshcore_open
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
private val usbFunctions by lazy { MeshcoreUsbFunctions(this) }
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
usbFunctions.configureFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
usbFunctions.dispose()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
package com.meshcore.meshcore_open
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.hardware.usb.UsbConstants
|
||||
import android.hardware.usb.UsbDevice
|
||||
import android.hardware.usb.UsbDeviceConnection
|
||||
import android.hardware.usb.UsbEndpoint
|
||||
import android.hardware.usb.UsbInterface
|
||||
import android.hardware.usb.UsbManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MeshcoreUsbFunctions(
|
||||
private val activity: FlutterActivity,
|
||||
) {
|
||||
private companion object {
|
||||
const val usbRecipientInterface = 0x01
|
||||
}
|
||||
|
||||
private val usbMethodChannelName = "meshcore_open/android_usb_serial"
|
||||
private val usbEventChannelName = "meshcore_open/android_usb_serial_events"
|
||||
private val usbPermissionAction = "com.meshcore.meshcore_open.USB_PERMISSION"
|
||||
|
||||
private val usbManager by lazy {
|
||||
activity.getSystemService(Context.USB_SERVICE) as UsbManager
|
||||
}
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val usbIoExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
|
||||
@Volatile private var eventSink: EventChannel.EventSink? = null
|
||||
@Volatile private var usbConnection: UsbDeviceConnection? = null
|
||||
@Volatile private var usbInEndpoint: UsbEndpoint? = null
|
||||
@Volatile private var usbOutEndpoint: UsbEndpoint? = null
|
||||
@Volatile private var controlInterface: UsbInterface? = null
|
||||
@Volatile private var dataInterface: UsbInterface? = null
|
||||
private var readThread: Thread? = null
|
||||
@Volatile private var isReading = false
|
||||
@Volatile private var connectedDeviceName: String? = null
|
||||
|
||||
private var pendingConnectResult: MethodChannel.Result? = null
|
||||
private var pendingConnectPortName: String? = null
|
||||
private var pendingConnectBaudRate: Int = 115200
|
||||
|
||||
private data class PortConfig(
|
||||
val controlInterface: UsbInterface?,
|
||||
val dataInterface: UsbInterface,
|
||||
val inEndpoint: UsbEndpoint,
|
||||
val outEndpoint: UsbEndpoint,
|
||||
)
|
||||
|
||||
private val permissionReceiver =
|
||||
object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
UsbManager.ACTION_USB_DEVICE_DETACHED -> {
|
||||
handleUsbDetached(intent)
|
||||
return
|
||||
}
|
||||
usbPermissionAction -> Unit
|
||||
else -> return
|
||||
}
|
||||
|
||||
val result = pendingConnectResult
|
||||
val portName = pendingConnectPortName
|
||||
pendingConnectResult = null
|
||||
pendingConnectPortName = null
|
||||
|
||||
if (result == null || portName == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val device = findUsbDevice(portName)
|
||||
if (device == null) {
|
||||
result.error(
|
||||
"usb_device_missing",
|
||||
null,
|
||||
null,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val granted =
|
||||
intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
|
||||
if (!granted || !usbManager.hasPermission(device)) {
|
||||
result.error("usb_permission_denied", null, null)
|
||||
return
|
||||
}
|
||||
|
||||
openUsbDevice(device, pendingConnectBaudRate, result)
|
||||
}
|
||||
}
|
||||
|
||||
fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
registerUsbPermissionReceiver()
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, usbMethodChannelName)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"listPorts" -> result.success(listUsbPorts())
|
||||
"connect" -> handleUsbConnect(call, result)
|
||||
"write" -> handleUsbWrite(call, result)
|
||||
"disconnect" -> {
|
||||
scheduleCloseUsbConnection {
|
||||
result.success(null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
EventChannel(flutterEngine.dartExecutor.binaryMessenger, usbEventChannelName)
|
||||
.setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
eventSink = events
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
eventSink = null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
closeUsbConnection()
|
||||
usbIoExecutor.shutdownNow()
|
||||
try {
|
||||
activity.unregisterReceiver(permissionReceiver)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerUsbPermissionReceiver() {
|
||||
val filter =
|
||||
IntentFilter().apply {
|
||||
addAction(usbPermissionAction)
|
||||
addAction(UsbManager.ACTION_USB_DEVICE_DETACHED)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
activity.registerReceiver(permissionReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
activity.registerReceiver(permissionReceiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun listUsbPorts(): List<String> {
|
||||
return usbManager.deviceList.values.map { device ->
|
||||
val productName = device.productName ?: "USB Serial Device"
|
||||
val vendorProduct =
|
||||
String.format(
|
||||
Locale.US,
|
||||
"VID:%04X PID:%04X",
|
||||
device.vendorId,
|
||||
device.productId,
|
||||
)
|
||||
"${device.deviceName} - $productName - $vendorProduct"
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleUsbConnect(call: MethodCall, result: MethodChannel.Result) {
|
||||
val portName = call.argument<String>("portName")
|
||||
val baudRate = call.argument<Int>("baudRate") ?: 115200
|
||||
if (portName.isNullOrBlank()) {
|
||||
result.error("usb_invalid_port", null, null)
|
||||
return
|
||||
}
|
||||
|
||||
val device = findUsbDevice(portName)
|
||||
if (device == null) {
|
||||
result.error("usb_device_missing", null, null)
|
||||
return
|
||||
}
|
||||
|
||||
if (usbManager.hasPermission(device)) {
|
||||
openUsbDevice(device, baudRate, result)
|
||||
return
|
||||
}
|
||||
|
||||
if (pendingConnectResult != null) {
|
||||
result.error("usb_busy", null, null)
|
||||
return
|
||||
}
|
||||
|
||||
pendingConnectResult = result
|
||||
pendingConnectPortName = portName
|
||||
pendingConnectBaudRate = baudRate
|
||||
|
||||
val permissionIntent = PendingIntent.getBroadcast(
|
||||
activity,
|
||||
0,
|
||||
Intent(usbPermissionAction).setPackage(activity.packageName),
|
||||
pendingIntentFlags(),
|
||||
)
|
||||
usbManager.requestPermission(device, permissionIntent)
|
||||
}
|
||||
|
||||
private fun handleUsbWrite(call: MethodCall, result: MethodChannel.Result) {
|
||||
val data = call.argument<ByteArray>("data")
|
||||
val connection = usbConnection
|
||||
val endpoint = usbOutEndpoint
|
||||
if (data == null) {
|
||||
result.error("usb_invalid_data", null, null)
|
||||
return
|
||||
}
|
||||
if (connection == null || endpoint == null) {
|
||||
result.error("usb_not_connected", null, null)
|
||||
return
|
||||
}
|
||||
|
||||
usbIoExecutor.execute {
|
||||
try {
|
||||
writeToDevice(data)
|
||||
mainHandler.post { result.success(null) }
|
||||
} catch (error: Exception) {
|
||||
mainHandler.post {
|
||||
result.error("usb_write_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findUsbDevice(portName: String): UsbDevice? {
|
||||
val devices = usbManager.deviceList.values
|
||||
val exactMatch = devices.firstOrNull { it.deviceName == portName }
|
||||
if (exactMatch != null) {
|
||||
return exactMatch
|
||||
}
|
||||
|
||||
val normalizedName = portName.substringBefore(" - ").trim()
|
||||
return devices.firstOrNull { it.deviceName == normalizedName }
|
||||
}
|
||||
|
||||
private fun openUsbDevice(
|
||||
device: UsbDevice,
|
||||
baudRate: Int,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
usbIoExecutor.execute {
|
||||
try {
|
||||
closeUsbConnection()
|
||||
|
||||
val config = resolvePortConfig(device)
|
||||
if (config == null) {
|
||||
mainHandler.post {
|
||||
result.error(
|
||||
"usb_driver_missing",
|
||||
null,
|
||||
null,
|
||||
)
|
||||
}
|
||||
return@execute
|
||||
}
|
||||
|
||||
val connection = usbManager.openDevice(device)
|
||||
if (connection == null) {
|
||||
mainHandler.post {
|
||||
result.error(
|
||||
"usb_open_failed",
|
||||
null,
|
||||
null,
|
||||
)
|
||||
}
|
||||
return@execute
|
||||
}
|
||||
|
||||
if (!connection.claimInterface(config.dataInterface, true)) {
|
||||
connection.close()
|
||||
mainHandler.post {
|
||||
result.error(
|
||||
"usb_open_failed",
|
||||
null,
|
||||
null,
|
||||
)
|
||||
}
|
||||
return@execute
|
||||
}
|
||||
|
||||
if (config.controlInterface != null &&
|
||||
config.controlInterface.id != config.dataInterface.id &&
|
||||
!connection.claimInterface(config.controlInterface, true)
|
||||
) {
|
||||
connection.releaseInterface(config.dataInterface)
|
||||
connection.close()
|
||||
mainHandler.post {
|
||||
result.error(
|
||||
"usb_open_failed",
|
||||
null,
|
||||
null,
|
||||
)
|
||||
}
|
||||
return@execute
|
||||
}
|
||||
|
||||
usbConnection = connection
|
||||
usbInEndpoint = config.inEndpoint
|
||||
usbOutEndpoint = config.outEndpoint
|
||||
controlInterface = config.controlInterface
|
||||
dataInterface = config.dataInterface
|
||||
|
||||
configureDevice(connection, config, baudRate)
|
||||
|
||||
connectedDeviceName = device.deviceName
|
||||
startReadLoop()
|
||||
|
||||
mainHandler.post {
|
||||
result.success(null)
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
closeUsbConnection()
|
||||
mainHandler.post {
|
||||
result.error("usb_connect_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePortConfig(device: UsbDevice): PortConfig? {
|
||||
var preferredDataInterface: UsbInterface? = null
|
||||
var preferredInEndpoint: UsbEndpoint? = null
|
||||
var preferredOutEndpoint: UsbEndpoint? = null
|
||||
var fallbackDataInterface: UsbInterface? = null
|
||||
var fallbackInEndpoint: UsbEndpoint? = null
|
||||
var fallbackOutEndpoint: UsbEndpoint? = null
|
||||
var preferredControlInterface: UsbInterface? = null
|
||||
|
||||
for (interfaceIndex in 0 until device.interfaceCount) {
|
||||
val usbInterface = device.getInterface(interfaceIndex)
|
||||
var inEndpoint: UsbEndpoint? = null
|
||||
var outEndpoint: UsbEndpoint? = null
|
||||
|
||||
for (endpointIndex in 0 until usbInterface.endpointCount) {
|
||||
val endpoint = usbInterface.getEndpoint(endpointIndex)
|
||||
if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
|
||||
continue
|
||||
}
|
||||
when (endpoint.direction) {
|
||||
UsbConstants.USB_DIR_IN -> if (inEndpoint == null) inEndpoint = endpoint
|
||||
UsbConstants.USB_DIR_OUT -> if (outEndpoint == null) outEndpoint = endpoint
|
||||
}
|
||||
}
|
||||
|
||||
val hasDataPair = inEndpoint != null && outEndpoint != null
|
||||
when {
|
||||
usbInterface.interfaceClass == UsbConstants.USB_CLASS_COMM &&
|
||||
preferredControlInterface == null -> {
|
||||
preferredControlInterface = usbInterface
|
||||
}
|
||||
hasDataPair &&
|
||||
usbInterface.interfaceClass == UsbConstants.USB_CLASS_CDC_DATA -> {
|
||||
preferredDataInterface = usbInterface
|
||||
preferredInEndpoint = inEndpoint
|
||||
preferredOutEndpoint = outEndpoint
|
||||
}
|
||||
hasDataPair && fallbackDataInterface == null -> {
|
||||
fallbackDataInterface = usbInterface
|
||||
fallbackInEndpoint = inEndpoint
|
||||
fallbackOutEndpoint = outEndpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dataInterface = preferredDataInterface ?: fallbackDataInterface ?: return null
|
||||
val inEndpoint = preferredInEndpoint ?: fallbackInEndpoint ?: return null
|
||||
val outEndpoint = preferredOutEndpoint ?: fallbackOutEndpoint ?: return null
|
||||
return PortConfig(preferredControlInterface, dataInterface, inEndpoint, outEndpoint)
|
||||
}
|
||||
|
||||
private fun configureDevice(
|
||||
connection: UsbDeviceConnection,
|
||||
config: PortConfig,
|
||||
baudRate: Int,
|
||||
) {
|
||||
val control = config.controlInterface ?: return
|
||||
val lineCoding =
|
||||
byteArrayOf(
|
||||
(baudRate and 0xFF).toByte(),
|
||||
((baudRate shr 8) and 0xFF).toByte(),
|
||||
((baudRate shr 16) and 0xFF).toByte(),
|
||||
((baudRate shr 24) and 0xFF).toByte(),
|
||||
0, // stop bits: 1
|
||||
0, // parity: none
|
||||
8, // data bits
|
||||
)
|
||||
|
||||
val lineCodingResult =
|
||||
connection.controlTransfer(
|
||||
UsbConstants.USB_DIR_OUT or
|
||||
UsbConstants.USB_TYPE_CLASS or
|
||||
usbRecipientInterface,
|
||||
0x20,
|
||||
0,
|
||||
control.id,
|
||||
lineCoding,
|
||||
lineCoding.size,
|
||||
1000,
|
||||
)
|
||||
if (lineCodingResult < 0) {
|
||||
throw IllegalStateException("Failed to configure USB line coding")
|
||||
}
|
||||
|
||||
val controlLineResult =
|
||||
connection.controlTransfer(
|
||||
UsbConstants.USB_DIR_OUT or
|
||||
UsbConstants.USB_TYPE_CLASS or
|
||||
usbRecipientInterface,
|
||||
0x22,
|
||||
0x0001, // DTR on, RTS off
|
||||
control.id,
|
||||
null,
|
||||
0,
|
||||
1000,
|
||||
)
|
||||
if (controlLineResult < 0) {
|
||||
throw IllegalStateException("Failed to configure USB control line state")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startReadLoop() {
|
||||
val connection = usbConnection ?: return
|
||||
val endpoint = usbInEndpoint ?: return
|
||||
|
||||
isReading = true
|
||||
readThread =
|
||||
Thread({
|
||||
val packetSize = endpoint.maxPacketSize.coerceAtLeast(64)
|
||||
val buffer = ByteArray(packetSize * 4)
|
||||
try {
|
||||
while (isReading) {
|
||||
val bytesRead = connection.bulkTransfer(endpoint, buffer, buffer.size, 250)
|
||||
if (!isReading) {
|
||||
break
|
||||
}
|
||||
if (bytesRead <= 0) {
|
||||
continue
|
||||
}
|
||||
val packet = buffer.copyOf(bytesRead)
|
||||
mainHandler.post {
|
||||
eventSink?.success(packet)
|
||||
}
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
if (isReading) {
|
||||
mainHandler.post {
|
||||
eventSink?.error(
|
||||
"usb_io_error",
|
||||
error.message ?: "USB serial I/O error",
|
||||
null,
|
||||
)
|
||||
}
|
||||
scheduleCloseUsbConnection()
|
||||
}
|
||||
}
|
||||
}, "MeshCoreUsbRead").also { thread ->
|
||||
thread.isDaemon = true
|
||||
thread.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeToDevice(data: ByteArray) {
|
||||
val connection = usbConnection ?: throw IllegalStateException("USB connection missing")
|
||||
val endpoint = usbOutEndpoint ?: throw IllegalStateException("USB output endpoint missing")
|
||||
var offset = 0
|
||||
val maxPacketSize = endpoint.maxPacketSize.coerceAtLeast(64)
|
||||
while (offset < data.size) {
|
||||
val chunkSize = minOf(maxPacketSize, data.size - offset)
|
||||
val chunk = data.copyOfRange(offset, offset + chunkSize)
|
||||
val bytesWritten = connection.bulkTransfer(endpoint, chunk, chunkSize, 1000)
|
||||
if (bytesWritten != chunkSize) {
|
||||
throw IllegalStateException("Short USB write: wrote $bytesWritten of $chunkSize bytes")
|
||||
}
|
||||
offset += chunkSize
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleCloseUsbConnection(onComplete: (() -> Unit)? = null) {
|
||||
usbIoExecutor.execute {
|
||||
closeUsbConnection()
|
||||
if (onComplete != null) {
|
||||
mainHandler.post(onComplete)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun closeUsbConnection() {
|
||||
isReading = false
|
||||
readThread?.interrupt()
|
||||
if (readThread != null && readThread !== Thread.currentThread()) {
|
||||
try {
|
||||
readThread?.join(300)
|
||||
} catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
readThread = null
|
||||
|
||||
val connection = usbConnection
|
||||
val claimedControl = controlInterface
|
||||
val claimedData = dataInterface
|
||||
|
||||
usbInEndpoint = null
|
||||
usbOutEndpoint = null
|
||||
controlInterface = null
|
||||
dataInterface = null
|
||||
usbConnection = null
|
||||
|
||||
if (connection != null) {
|
||||
if (claimedControl != null) {
|
||||
try {
|
||||
connection.releaseInterface(claimedControl)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
if (claimedData != null && claimedData.id != claimedControl?.id) {
|
||||
try {
|
||||
connection.releaseInterface(claimedData)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
connection.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
connectedDeviceName = null
|
||||
}
|
||||
|
||||
private fun handleUsbDetached(intent: Intent) {
|
||||
val detachedDevice =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
|
||||
}
|
||||
|
||||
val detachedName = detachedDevice?.deviceName ?: return
|
||||
|
||||
if (pendingConnectPortName == detachedName) {
|
||||
pendingConnectResult?.error(
|
||||
"usb_device_detached",
|
||||
"USB device was removed before the connection completed",
|
||||
null,
|
||||
)
|
||||
pendingConnectResult = null
|
||||
pendingConnectPortName = null
|
||||
}
|
||||
|
||||
if (connectedDeviceName == detachedName) {
|
||||
scheduleCloseUsbConnection {
|
||||
eventSink?.error(
|
||||
"usb_device_detached",
|
||||
"USB device was disconnected",
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun pendingIntentFlags(): Int {
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
flags = flags or PendingIntent.FLAG_MUTABLE
|
||||
}
|
||||
return flags
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M268-240 42-466l57-56 170 170 56 56-57 56Zm226 0L268-466l56-57 170 170 368-368 56 57-424 424Zm0-226-57-56 198-198 57 56-198 198Z"/></svg>
|
||||
|
After Width: | Height: | Size: 253 B |
Binary file not shown.
|
After Width: | Height: | Size: 579 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770562336,
|
||||
"narHash": "sha256-ub1gpAONMFsT/GU2hV6ZWJjur8rJ6kKxdm9IlCT0j84=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "d6c71932130818840fc8fe9509cf50be8c64634f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
description = "MeshCore Flutter Application";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
# Flutter and Dart
|
||||
flutter
|
||||
dart
|
||||
|
||||
# Java (required for Android development)
|
||||
jdk17
|
||||
|
||||
# Android development tools
|
||||
android-tools
|
||||
gradle
|
||||
|
||||
# For the shell hook to set up the environment for Flutter development
|
||||
gtk3
|
||||
glib
|
||||
sysprof
|
||||
libclang
|
||||
cmake
|
||||
ninja
|
||||
pkg-config
|
||||
libdatrie
|
||||
|
||||
# Additional tools for installing Android SDK if not present
|
||||
curl
|
||||
unzip
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "MeshCore Flutter Development Environment"
|
||||
export PKG_CONFIG_PATH="${pkgs.gtk3}/lib/pkgconfig:${pkgs.glib}/lib/pkgconfig:${pkgs.sysprof}/lib/pkgconfig:$PKG_CONFIG_PATH"
|
||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [pkgs.gtk3 pkgs.glib pkgs.sysprof pkgs.libdatrie]}:$LD_LIBRARY_PATH"
|
||||
export CMAKE_INSTALL_PREFIX="$PWD/build/bundle"
|
||||
|
||||
# Setup Android SDK in home directory (standard location)
|
||||
export ANDROID_HOME="$HOME/Android/Sdk"
|
||||
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools/bin:$PATH"
|
||||
|
||||
echo "Android SDK: $ANDROID_HOME"
|
||||
echo ""
|
||||
|
||||
# Check if Android SDK exists and offer to download if not
|
||||
if [ ! -d "$ANDROID_HOME" ]; then
|
||||
echo "WARNING: Android SDK not found at $ANDROID_HOME"
|
||||
echo ""
|
||||
echo "To download and set up the Android SDK, run this command:"
|
||||
echo ""
|
||||
cat << 'EOF'
|
||||
mkdir -p ~/Android/Sdk && cd ~/Android/Sdk && \
|
||||
curl -o cmdline-tools.zip ${if pkgs.stdenv.isDarwin then "https://dl.google.com/android/repository/commandlinetools-mac-10406996_latest.zip" else "https://dl.google.com/android/repository/commandlinetools-linux-10406996_latest.zip"} && \
|
||||
unzip -q cmdline-tools.zip && \
|
||||
mkdir -p cmdline-tools/latest && \
|
||||
mv cmdline-tools/* cmdline-tools/latest/ 2>/dev/null || echo "Warning: failed to move Android cmdline-tools into 'latest' directory; please check your SDK layout." >&2 && \
|
||||
rm cmdline-tools.zip && \
|
||||
cd cmdline-tools/latest/bin && \
|
||||
yes | ./sdkmanager --sdk_root=~/Android/Sdk 'platform-tools' && \
|
||||
echo "Android SDK setup complete!"
|
||||
EOF
|
||||
echo ""
|
||||
echo "Then run 'flutter doctor' again to verify."
|
||||
echo ""
|
||||
else
|
||||
echo "Android SDK found at $ANDROID_HOME"
|
||||
fi
|
||||
|
||||
echo "To check that everything is set up correctly, run 'flutter doctor' and ensure there are no issues."
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
platform :ios, '12.0'
|
||||
platform :ios, '15.5'
|
||||
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_blue_plus_darwin (0.0.2):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- flutter_foreground_task (0.0.1):
|
||||
- Flutter
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- GoogleDataTransport (10.1.0):
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- GoogleMLKit/BarcodeScanning (7.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitBarcodeScanning (~> 6.0.0)
|
||||
- GoogleMLKit/MLKitCore (7.0.0):
|
||||
- MLKitCommon (~> 12.0.0)
|
||||
- GoogleToolboxForMac/Defines (4.2.1)
|
||||
- GoogleToolboxForMac/Logger (4.2.1):
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- GoogleUtilities/Environment (8.1.0):
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (8.1.0)
|
||||
- GoogleUtilities/UserDefaults (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- MLImage (1.0.0-beta6)
|
||||
- MLKitBarcodeScanning (6.0.0):
|
||||
- MLKitCommon (~> 12.0)
|
||||
- MLKitVision (~> 8.0)
|
||||
- MLKitCommon (12.0.0):
|
||||
- GoogleDataTransport (~> 10.0)
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GoogleUtilities/Logger (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLKitVision (8.0.0):
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLImage (= 1.0.0-beta6)
|
||||
- MLKitCommon (~> 12.0)
|
||||
- mobile_scanner (6.0.2):
|
||||
- Flutter
|
||||
- GoogleMLKit/BarcodeScanning (~> 7.0.0)
|
||||
- nanopb (3.30910.0):
|
||||
- nanopb/decode (= 3.30910.0)
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- PromisesObjC (2.4.0)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- GoogleDataTransport
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
- GoogleUtilities
|
||||
- GTMSessionFetcher
|
||||
- MLImage
|
||||
- MLKitBarcodeScanning
|
||||
- MLKitCommon
|
||||
- MLKitVision
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_blue_plus_darwin:
|
||||
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
||||
flutter_foreground_task:
|
||||
:path: ".symlinks/plugins/flutter_foreground_task/ios"
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||
flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
|
||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
|
||||
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
|
||||
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
|
||||
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
|
||||
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
PODFILE CHECKSUM: 570da2a631486c6bd6496bed1e605e63e2471be5
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
@@ -14,6 +14,7 @@
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
9A698254711B63C3940A64CB /* libPods-Runner.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 4268181FCF3E12817B700E9C /* libPods-Runner.a */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -42,9 +43,13 @@
|
||||
/* Begin PBXFileReference section */
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
24A76623340E493BD4C25C5C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
40AC50CE3E1D4278E82498CF /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
4268181FCF3E12817B700E9C /* libPods-Runner.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Runner.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
718BC7DCCFC5C370705C12E5 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -62,6 +67,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
9A698254711B63C3940A64CB /* libPods-Runner.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -94,6 +100,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
DEE6F094D3B70E76087722E1 /* Pods */,
|
||||
DAE613E34DF694C2E33B64C7 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -121,6 +129,25 @@
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DAE613E34DF694C2E33B64C7 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4268181FCF3E12817B700E9C /* libPods-Runner.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DEE6F094D3B70E76087722E1 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
40AC50CE3E1D4278E82498CF /* Pods-Runner.debug.xcconfig */,
|
||||
24A76623340E493BD4C25C5C /* Pods-Runner.release.xcconfig */,
|
||||
718BC7DCCFC5C370705C12E5 /* Pods-Runner.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -145,12 +172,14 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
DE3B2E091393835C0B38492E /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
B788CEDB957A87EE8AC593BB /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -253,6 +282,45 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
B788CEDB957A87EE8AC593BB /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
DE3B2E091393835C0B38492E /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -368,7 +436,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -384,7 +452,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -401,7 +469,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -416,7 +484,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -547,7 +615,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -569,7 +637,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.meshcore.meshcoreOpen;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.monitormx.meshcoreopen;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
+3
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -53,5 +53,12 @@
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app uses the camera to scan QR codes for joining communities.</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>http</string>
|
||||
<string>https</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -3,3 +3,4 @@ template-arb-file: app_en.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
output-class: AppLocalizations
|
||||
nullable-getter: false
|
||||
untranslated-messages-file: untranslated.json
|
||||
|
||||
+2579
-510
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../services/app_debug_log_service.dart';
|
||||
import '../services/tcp_transport_service.dart';
|
||||
|
||||
/// Manages TCP transport for MeshCore devices.
|
||||
///
|
||||
/// Owns the [TcpTransportService] and TCP-specific connection state.
|
||||
/// The main [MeshCoreConnector] delegates all TCP operations here.
|
||||
class MeshCoreTcpConnector {
|
||||
final TcpTransportService _service = TcpTransportService();
|
||||
AppDebugLogService? _debugLog;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
|
||||
// --- Getters ---
|
||||
String? get activeEndpoint => _service.activeEndpoint;
|
||||
bool get isConnected => _service.isConnected;
|
||||
|
||||
// --- Configuration ---
|
||||
void setDebugLogService(AppDebugLogService? service) {
|
||||
_debugLog = service;
|
||||
_service.setDebugLogService(service);
|
||||
}
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
Future<void> connect({required String host, required int port}) async {
|
||||
_debugLog?.info('TcpConnector.connect endpoint=$host:$port', tag: 'TCP');
|
||||
await _frameSubscription?.cancel();
|
||||
_frameSubscription = null;
|
||||
await _service.connect(host: host, port: port);
|
||||
_debugLog?.info(
|
||||
'TcpConnector.connect done, endpoint=${_service.activeEndpoint}',
|
||||
tag: 'TCP',
|
||||
);
|
||||
}
|
||||
|
||||
StreamSubscription<Uint8List> listenFrames({
|
||||
required void Function(Uint8List) onFrame,
|
||||
required void Function(Object, StackTrace?) onError,
|
||||
required void Function() onDone,
|
||||
}) {
|
||||
_frameSubscription = _service.frameStream.listen(
|
||||
onFrame,
|
||||
onError: onError,
|
||||
onDone: onDone,
|
||||
);
|
||||
return _frameSubscription!;
|
||||
}
|
||||
|
||||
Future<void> cancelFrameSubscription() async {
|
||||
await _frameSubscription?.cancel();
|
||||
_frameSubscription = null;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
if (!_service.isConnected && _frameSubscription == null) return;
|
||||
_debugLog?.info('TcpConnector.disconnect', tag: 'TCP');
|
||||
await _frameSubscription?.cancel();
|
||||
_frameSubscription = null;
|
||||
await _service.disconnect();
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) => _service.write(data);
|
||||
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_service.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../services/app_debug_log_service.dart';
|
||||
import '../services/usb_serial_service.dart';
|
||||
|
||||
/// Manages USB serial transport for MeshCore devices.
|
||||
///
|
||||
/// Owns the [UsbSerialService] and USB-specific connection state.
|
||||
/// The main [MeshCoreConnector] delegates all USB operations here.
|
||||
class MeshCoreUsbManager {
|
||||
MeshCoreUsbManager();
|
||||
|
||||
final UsbSerialService _service = UsbSerialService();
|
||||
AppDebugLogService? _debugLog;
|
||||
String? _activePortKey;
|
||||
String? _activePortLabel;
|
||||
|
||||
// --- Getters ---
|
||||
String? get activePortKey => _activePortKey;
|
||||
String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
|
||||
bool get isConnected => _service.isConnected;
|
||||
Stream<Uint8List> get frameStream => _service.frameStream;
|
||||
|
||||
// --- Configuration ---
|
||||
Future<List<String>> listPorts() => _service.listPorts();
|
||||
|
||||
void setRequestPortLabel(String label) => _service.setRequestPortLabel(label);
|
||||
|
||||
void setFallbackDeviceName(String label) =>
|
||||
_service.setFallbackDeviceName(label);
|
||||
|
||||
void setDebugLogService(AppDebugLogService? service) {
|
||||
_debugLog = service;
|
||||
_service.setDebugLogService(service);
|
||||
}
|
||||
|
||||
// --- Connection lifecycle ---
|
||||
Future<void> connect({
|
||||
required String portName,
|
||||
int baudRate = 115200,
|
||||
}) async {
|
||||
_debugLog?.info(
|
||||
'UsbManager.connect: portName=$portName baud=$baudRate',
|
||||
tag: 'USB',
|
||||
);
|
||||
await _service.connect(portName: portName, baudRate: baudRate);
|
||||
_activePortKey = _service.activePortKey ?? portName;
|
||||
_activePortLabel = _service.activePortDisplayLabel ?? portName;
|
||||
_debugLog?.info(
|
||||
'UsbManager.connect: done, key=$_activePortKey label=$_activePortLabel',
|
||||
tag: 'USB',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
if (!_service.isConnected && _activePortKey == null) {
|
||||
return;
|
||||
}
|
||||
_debugLog?.info('UsbManager.disconnect', tag: 'USB');
|
||||
await _service.disconnect();
|
||||
_activePortKey = null;
|
||||
_activePortLabel = null;
|
||||
}
|
||||
|
||||
Future<void> write(Uint8List data) => _service.write(data);
|
||||
|
||||
Future<void> writeRaw(Uint8List data) => _service.writeRaw(data);
|
||||
|
||||
// --- Label management ---
|
||||
void updateConnectedLabel(String selfName) {
|
||||
_service.updateConnectedLabel(selfName);
|
||||
_activePortLabel = _service.activePortDisplayLabel ?? _activePortLabel;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_service.dispose();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'dart:typed_data';
|
||||
// Buffer Reader - sequential binary data reader with pointer tracking
|
||||
class BufferReader {
|
||||
int _pointer = 0;
|
||||
int _lastPointer = 0;
|
||||
final Uint8List _buffer;
|
||||
|
||||
BufferReader(Uint8List data) : _buffer = Uint8List.fromList(data);
|
||||
@@ -13,16 +14,41 @@ class BufferReader {
|
||||
int readByte() => readBytes(1)[0];
|
||||
|
||||
Uint8List readBytes(int count) {
|
||||
_lastPointer = _pointer;
|
||||
if (_pointer + count > _buffer.length) {
|
||||
throw RangeError(
|
||||
'Attempted to read $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||||
);
|
||||
}
|
||||
final data = _buffer.sublist(_pointer, _pointer + count);
|
||||
_pointer += count;
|
||||
return data;
|
||||
}
|
||||
|
||||
void skipBytes(int count) {
|
||||
_lastPointer = _pointer;
|
||||
if (_pointer + count > _buffer.length) {
|
||||
throw RangeError(
|
||||
'Attempted to skip $count bytes at offset $_pointer, but only $remaining bytes remaining in buffer of length ${_buffer.length}',
|
||||
);
|
||||
}
|
||||
_pointer += count;
|
||||
}
|
||||
|
||||
Uint8List readRemainingBytes() => readBytes(remaining);
|
||||
|
||||
String readString() => utf8.decode(readRemainingBytes(), allowMalformed: true);
|
||||
String readString() {
|
||||
_lastPointer = _pointer;
|
||||
final value = readRemainingBytes();
|
||||
try {
|
||||
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
||||
} catch (e) {
|
||||
return String.fromCharCodes(value); // Latin-1 fallback
|
||||
}
|
||||
}
|
||||
|
||||
String readCString(int maxLength) {
|
||||
String readCStringGreedy(int maxLength) {
|
||||
_lastPointer = _pointer;
|
||||
final value = <int>[];
|
||||
final bytes = readBytes(maxLength);
|
||||
for (final byte in bytes) {
|
||||
@@ -36,21 +62,48 @@ class BufferReader {
|
||||
}
|
||||
}
|
||||
|
||||
String readCString(int maxLength) {
|
||||
final backupPointer = _pointer;
|
||||
final value = <int>[];
|
||||
int counter = 0;
|
||||
while (counter < maxLength) {
|
||||
final byte = readByte();
|
||||
if (byte == 0) break;
|
||||
value.add(byte);
|
||||
counter++;
|
||||
}
|
||||
_lastPointer = backupPointer;
|
||||
try {
|
||||
return utf8.decode(Uint8List.fromList(value), allowMalformed: true);
|
||||
} catch (e) {
|
||||
return String.fromCharCodes(value); // Latin-1 fallback
|
||||
}
|
||||
}
|
||||
|
||||
int readUInt8() => readBytes(1).buffer.asByteData().getUint8(0);
|
||||
int readInt8() => readBytes(1).buffer.asByteData().getInt8(0);
|
||||
int readUInt16LE() => readBytes(2).buffer.asByteData().getUint16(0, Endian.little);
|
||||
int readUInt16BE() => readBytes(2).buffer.asByteData().getUint16(0, Endian.big);
|
||||
int readUInt32LE() => readBytes(4).buffer.asByteData().getUint32(0, Endian.little);
|
||||
int readUInt32BE() => readBytes(4).buffer.asByteData().getUint32(0, Endian.big);
|
||||
int readInt16LE() => readBytes(2).buffer.asByteData().getInt16(0, Endian.little);
|
||||
int readUInt16LE() =>
|
||||
readBytes(2).buffer.asByteData().getUint16(0, Endian.little);
|
||||
int readUInt16BE() =>
|
||||
readBytes(2).buffer.asByteData().getUint16(0, Endian.big);
|
||||
int readUInt32LE() =>
|
||||
readBytes(4).buffer.asByteData().getUint32(0, Endian.little);
|
||||
int readUInt32BE() =>
|
||||
readBytes(4).buffer.asByteData().getUint32(0, Endian.big);
|
||||
int readInt16LE() =>
|
||||
readBytes(2).buffer.asByteData().getInt16(0, Endian.little);
|
||||
int readInt16BE() => readBytes(2).buffer.asByteData().getInt16(0, Endian.big);
|
||||
int readInt32LE() => readBytes(4).buffer.asByteData().getInt32(0, Endian.little);
|
||||
int readInt32LE() =>
|
||||
readBytes(4).buffer.asByteData().getInt32(0, Endian.little);
|
||||
|
||||
int readInt24BE() {
|
||||
var value = (readByte() << 16) | (readByte() << 8) | readByte();
|
||||
if ((value & 0x800000) != 0) value -= 0x1000000;
|
||||
return value;
|
||||
}
|
||||
|
||||
void resetPointer() => _pointer = 0;
|
||||
void rewind() => _pointer = _lastPointer;
|
||||
}
|
||||
|
||||
// Buffer Writer - accumulating binary data builder
|
||||
@@ -63,21 +116,25 @@ class BufferWriter {
|
||||
void writeBytes(Uint8List bytes) => _builder.add(bytes);
|
||||
|
||||
void writeUInt16LE(int num) {
|
||||
final bytes = Uint8List(2)..buffer.asByteData().setUint16(0, num, Endian.little);
|
||||
final bytes = Uint8List(2)
|
||||
..buffer.asByteData().setUint16(0, num, Endian.little);
|
||||
writeBytes(bytes);
|
||||
}
|
||||
|
||||
void writeUInt32LE(int num) {
|
||||
final bytes = Uint8List(4)..buffer.asByteData().setUint32(0, num, Endian.little);
|
||||
final bytes = Uint8List(4)
|
||||
..buffer.asByteData().setUint32(0, num, Endian.little);
|
||||
writeBytes(bytes);
|
||||
}
|
||||
|
||||
void writeInt32LE(int num) {
|
||||
final bytes = Uint8List(4)..buffer.asByteData().setInt32(0, num, Endian.little);
|
||||
final bytes = Uint8List(4)
|
||||
..buffer.asByteData().setInt32(0, num, Endian.little);
|
||||
writeBytes(bytes);
|
||||
}
|
||||
|
||||
void writeString(String string) => writeBytes(Uint8List.fromList(utf8.encode(string)));
|
||||
void writeString(String string) =>
|
||||
writeBytes(Uint8List.fromList(utf8.encode(string)));
|
||||
|
||||
void writeCString(String string, int maxLength) {
|
||||
final bytes = Uint8List(maxLength);
|
||||
@@ -87,6 +144,40 @@ class BufferWriter {
|
||||
}
|
||||
writeBytes(bytes);
|
||||
}
|
||||
|
||||
void writeHex(String hex) {
|
||||
writeBytes(hex2Uint8List(hex));
|
||||
}
|
||||
|
||||
void writeBytesPadded(Uint8List bytes, int totalLength) {
|
||||
// Path data (64 bytes, zero-padded)
|
||||
final bytesPadded = Uint8List(totalLength);
|
||||
final len = bytes.length < totalLength ? bytes.length : totalLength;
|
||||
if (bytes.isNotEmpty && len > 0) {
|
||||
final copyLen = bytes.length < totalLength ? bytes.length : totalLength;
|
||||
for (int i = 0; i < copyLen; i++) {
|
||||
bytesPadded[i] = bytes[i];
|
||||
}
|
||||
}
|
||||
writeBytes(bytesPadded);
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List hex2Uint8List(String hex) {
|
||||
// Validate hex string length is even and not empty
|
||||
if (hex.isEmpty || hex.length % 2 != 0) {
|
||||
throw FormatException('Invalid hex string length: ${hex.length}');
|
||||
}
|
||||
List<int> result = [];
|
||||
for (int i = 0; i < hex.length ~/ 2; i++) {
|
||||
final hexByte = hex.substring(i * 2, i * 2 + 2);
|
||||
final byte = int.tryParse(hexByte, radix: 16);
|
||||
if (byte == null) {
|
||||
throw FormatException('Invalid hex characters at position $i: $hexByte');
|
||||
}
|
||||
result.add(byte);
|
||||
}
|
||||
return Uint8List.fromList(result);
|
||||
}
|
||||
|
||||
// Command codes (to device)
|
||||
@@ -116,9 +207,15 @@ const int cmdSendStatusReq = 27;
|
||||
const int cmdGetContactByKey = 30;
|
||||
const int cmdGetChannel = 31;
|
||||
const int cmdSetChannel = 32;
|
||||
const int cmdGetRadioSettings = 57;
|
||||
const int cmdGetTelemetryReq = 39;
|
||||
const int cmdSendTracePath = 36;
|
||||
const int cmdSetOtherParams = 38;
|
||||
const int cmdSendAnonReq = 57;
|
||||
const int cmdSendTelemetryReq = 39;
|
||||
const int cmdGetCustomVar = 40;
|
||||
const int cmdSetCustomVar = 41;
|
||||
const int cmdSendBinaryReq = 50;
|
||||
const int cmdSetAutoAddConfig = 58;
|
||||
const int cmdGetAutoAddConfig = 59;
|
||||
|
||||
// Text message types
|
||||
const int txtTypePlain = 0;
|
||||
@@ -129,7 +226,7 @@ const int reqTypeGetStatus = 0x01;
|
||||
const int reqTypeKeepAlive = 0x02;
|
||||
const int reqTypeGetTelemetry = 0x03;
|
||||
const int reqTypeGetAccessList = 0x05;
|
||||
const int reqTypeGetNeighbours = 0x06;
|
||||
const int reqTypeGetNeighbors = 0x06;
|
||||
|
||||
// Repeater response codes
|
||||
const int respServerLoginOk = 0;
|
||||
@@ -146,12 +243,14 @@ const int respCodeContactMsgRecv = 7;
|
||||
const int respCodeChannelMsgRecv = 8;
|
||||
const int respCodeCurrTime = 9;
|
||||
const int respCodeNoMoreMessages = 10;
|
||||
const int respCodeExportContact = 11;
|
||||
const int respCodeBattAndStorage = 12;
|
||||
const int respCodeDeviceInfo = 13;
|
||||
const int respCodeContactMsgRecvV3 = 16;
|
||||
const int respCodeChannelMsgRecvV3 = 17;
|
||||
const int respCodeChannelInfo = 18;
|
||||
const int respCodeRadioSettings = 25;
|
||||
const int respCodeCustomVars = 21;
|
||||
const int respCodeAutoAddConfig = 25;
|
||||
|
||||
// Push codes (async from device)
|
||||
const int pushCodeAdvert = 0x80;
|
||||
@@ -162,17 +261,57 @@ const int pushCodeLoginSuccess = 0x85;
|
||||
const int pushCodeLoginFail = 0x86;
|
||||
const int pushCodeStatusResponse = 0x87;
|
||||
const int pushCodeLogRxData = 0x88;
|
||||
const int pushCodeTraceData = 0x89;
|
||||
const int pushCodeNewAdvert = 0x8A;
|
||||
const int pushCodeTelemetryResponse = 0x8B;
|
||||
const int pushCodeBinaryResponse = 0x8C;
|
||||
|
||||
|
||||
// Contact/advertisement types
|
||||
const int advTypeChat = 1;
|
||||
const int advTypeRepeater = 2;
|
||||
const int advTypeRoom = 3;
|
||||
const int advTypeSensor = 4;
|
||||
|
||||
const int teleModeDeny = 0;
|
||||
const int teleModeAllowFlags = 1; // use contact.flags
|
||||
const int teleModeAllowAll = 2;
|
||||
|
||||
// Payload Types
|
||||
const int payloadTypeREQ =
|
||||
0x00; // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
||||
const int payloadTypeRESPONSE =
|
||||
0x01; // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)
|
||||
const int payloadTypeTXTMSG =
|
||||
0x02; // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text)
|
||||
const int payloadTypeACK = 0x03; // a simple ack
|
||||
const int payloadTypeADVERT = 0x04; // a node advertising its Identity
|
||||
const int payloadTypeGRPTXT =
|
||||
0x05; // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, "name: msg")
|
||||
const int payloadTypeGRPDATA =
|
||||
0x06; // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: timestamp, blob)
|
||||
const int payloadTypeANONREQ =
|
||||
0x07; // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)
|
||||
const int payloadTypePATH =
|
||||
0x08; // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)
|
||||
const int payloadTypeTRACE = 0x09; // trace a path, collecting SNI for each hop
|
||||
const int payloadTypeMULTIPART = 0x0A; // packet is one of a set of packets
|
||||
const int payloadTypeCONTROL = 0x0B; // a control/discovery packet
|
||||
//...
|
||||
const int payloadTypeRawCustom =
|
||||
0x0F; // custom packet as raw bytes, for applications with custom encryption, payloads, etc
|
||||
|
||||
//auto-add flags
|
||||
const int autoAddOverwriteOldestFlag =
|
||||
1 << 0; // 0x01 - overwrite oldest non-favourite when full
|
||||
const int autoAddChatFlag =
|
||||
1 << 1; // 0x02 - auto-add Chat (Companion) (ADV_TYPE_CHAT)
|
||||
const int autoAddRepeaterFlag =
|
||||
1 << 2; // 0x04 - auto-add Repeater (ADV_TYPE_REPEATER)
|
||||
const int autoAddRoomServerFlag =
|
||||
1 << 3; // 0x08 - auto-add Room Server (ADV_TYPE_ROOM)
|
||||
const int autoAddSensorFlag =
|
||||
1 << 4; // 0x10 - auto-add Sensor (ADV_TYPE_SENSOR)
|
||||
|
||||
// Sizes
|
||||
const int pubKeySize = 32;
|
||||
const int maxPathSize = 64;
|
||||
@@ -182,8 +321,10 @@ const int maxFrameSize = 172;
|
||||
const int appProtocolVersion = 3;
|
||||
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
|
||||
const int maxTextPayloadBytes = 160;
|
||||
const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1;
|
||||
const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1;
|
||||
const int _sendTextMsgOverheadBytes =
|
||||
1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
|
||||
const int _sendChannelTextMsgOverheadBytes =
|
||||
1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
|
||||
|
||||
int maxContactMessageBytes() {
|
||||
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
|
||||
@@ -214,13 +355,17 @@ int _minPositive(int a, int b) {
|
||||
const int contactPubKeyOffset = 1;
|
||||
const int contactTypeOffset = 33;
|
||||
const int contactFlagsOffset = 34;
|
||||
const int contactFlagFavorite = 0x01;
|
||||
const int contactFlagTeleBase = 0x02; // 'base' permission includes battery
|
||||
const int contactFlagTeleLoc = 0x04;
|
||||
const int contactFlagTeleEnv = 0x08; //access environment sensors
|
||||
const int contactPathLenOffset = 35;
|
||||
const int contactPathOffset = 36;
|
||||
const int contactNameOffset = 100;
|
||||
const int contactTimestampOffset = 132;
|
||||
const int contactLatOffset = 136;
|
||||
const int contactLonOffset = 140;
|
||||
const int contactLastmodOffset = 144;
|
||||
const int contactLastModOffset = 144;
|
||||
const int contactFrameSize = 148;
|
||||
|
||||
// Message frame offsets
|
||||
@@ -233,10 +378,7 @@ class ParsedContactText {
|
||||
final Uint8List senderPrefix;
|
||||
final String text;
|
||||
|
||||
const ParsedContactText({
|
||||
required this.senderPrefix,
|
||||
required this.text,
|
||||
});
|
||||
const ParsedContactText({required this.senderPrefix, required this.text});
|
||||
}
|
||||
|
||||
ParsedContactText? parseContactMessageText(Uint8List frame) {
|
||||
@@ -265,10 +407,17 @@ ParsedContactText? parseContactMessageText(Uint8List frame) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var text = readCString(frame, baseTextOffset, frame.length - baseTextOffset).trim();
|
||||
var text = readCString(
|
||||
frame,
|
||||
baseTextOffset,
|
||||
frame.length - baseTextOffset,
|
||||
).trim();
|
||||
if (text.isEmpty && frame.length > baseTextOffset + 4) {
|
||||
text =
|
||||
readCString(frame, baseTextOffset + 4, frame.length - (baseTextOffset + 4)).trim();
|
||||
text = readCString(
|
||||
frame,
|
||||
baseTextOffset + 4,
|
||||
frame.length - (baseTextOffset + 4),
|
||||
).trim();
|
||||
}
|
||||
if (text.isEmpty) return null;
|
||||
|
||||
@@ -362,7 +511,8 @@ Uint8List buildSendTextMsgFrame(
|
||||
int attempt = 0,
|
||||
int? timestampSeconds,
|
||||
}) {
|
||||
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
final timestamp =
|
||||
timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTxtMsg);
|
||||
writer.writeByte(txtTypePlain);
|
||||
@@ -444,7 +594,9 @@ Uint8List buildSendSelfAdvertFrame({bool flood = false}) {
|
||||
// Format: [cmd][name...]
|
||||
Uint8List buildSetAdvertNameFrame(String name) {
|
||||
final nameBytes = utf8.encode(name);
|
||||
final nameLen = nameBytes.length < maxNameSize ? nameBytes.length : maxNameSize - 1;
|
||||
final nameLen = nameBytes.length < maxNameSize
|
||||
? nameBytes.length
|
||||
: maxNameSize - 1;
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetAdvertName);
|
||||
writer.writeBytes(Uint8List.fromList(nameBytes.sublist(0, nameLen)));
|
||||
@@ -461,6 +613,14 @@ Uint8List buildSetAdvertLatLonFrame(double lat, double lon) {
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
Uint8List buildSetCustomVarFrame(String value) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetCustomVar);
|
||||
writer.writeString(value);
|
||||
writer.writeByte(0);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_REBOOT frame
|
||||
// Format: [cmd]["reboot"]
|
||||
Uint8List buildRebootFrame() {
|
||||
@@ -494,18 +654,29 @@ Uint8List buildSetChannelFrame(int channelIndex, String name, Uint8List psk) {
|
||||
}
|
||||
|
||||
// Build CMD_SET_RADIO_PARAMS frame
|
||||
// Format: [cmd][freq x4][bw x4][sf][cr]
|
||||
// Format: [cmd][freq x4][bw x4][sf][cr] (pre-v9)
|
||||
// [cmd][freq x4][bw x4][sf][cr][repeat] (firmware v9+)
|
||||
// freq: frequency in Hz (300000-2500000)
|
||||
// bw: bandwidth in Hz (7000-500000)
|
||||
// sf: spreading factor (5-12)
|
||||
// cr: coding rate (5-8)
|
||||
Uint8List buildSetRadioParamsFrame(int freqHz, int bwHz, int sf, int cr) {
|
||||
// clientRepeat: enable off-grid packet repeat (firmware v9+, omit for older)
|
||||
Uint8List buildSetRadioParamsFrame(
|
||||
int freqHz,
|
||||
int bwHz,
|
||||
int sf,
|
||||
int cr, {
|
||||
bool? clientRepeat,
|
||||
}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetRadioParams);
|
||||
writer.writeUInt32LE(freqHz);
|
||||
writer.writeUInt32LE(bwHz);
|
||||
writer.writeByte(sf);
|
||||
writer.writeByte(cr);
|
||||
if (clientRepeat != null) {
|
||||
writer.writeByte(clientRepeat ? 1 : 0);
|
||||
}
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
@@ -525,14 +696,17 @@ Uint8List buildResetPathFrame(Uint8List pubKey) {
|
||||
}
|
||||
|
||||
// Build CMD_ADD_UPDATE_CONTACT frame to set custom path
|
||||
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][timestamp x4]
|
||||
// Format: [cmd][pub_key x32][type][flags][path_len][path x64][name x32][Lat? x4, Lon? x4][timestamp? x4]
|
||||
Uint8List buildUpdateContactPathFrame(
|
||||
Uint8List pubKey,
|
||||
Uint8List customPath,
|
||||
Uint8List path,
|
||||
int pathLen, {
|
||||
int type = 1, // ADV_TYPE_CHAT
|
||||
int flags = 0,
|
||||
String name = '',
|
||||
double? lat,
|
||||
double? lon,
|
||||
DateTime? lastModified,
|
||||
}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdAddUpdateContact);
|
||||
@@ -541,15 +715,7 @@ Uint8List buildUpdateContactPathFrame(
|
||||
writer.writeByte(flags);
|
||||
writer.writeByte(pathLen);
|
||||
|
||||
// Path data (64 bytes, zero-padded)
|
||||
final pathPadded = Uint8List(maxPathSize);
|
||||
if (customPath.isNotEmpty && pathLen > 0) {
|
||||
final copyLen = customPath.length < maxPathSize ? customPath.length : maxPathSize;
|
||||
for (int i = 0; i < copyLen; i++) {
|
||||
pathPadded[i] = customPath[i];
|
||||
}
|
||||
}
|
||||
writer.writeBytes(pathPadded);
|
||||
writer.writeBytesPadded(path, maxPathSize);
|
||||
|
||||
// Name (32 bytes, null-padded)
|
||||
writer.writeCString(name, maxNameSize);
|
||||
@@ -558,6 +724,27 @@ Uint8List buildUpdateContactPathFrame(
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
writer.writeUInt32LE(timestamp);
|
||||
|
||||
if ((lat == null || lon == null) && lastModified != null) {
|
||||
// If lat/lon not provided, write zeros
|
||||
writer.writeInt32LE(0);
|
||||
writer.writeInt32LE(0);
|
||||
} else {
|
||||
// Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
|
||||
// Latitude
|
||||
final latitude = lat ?? 0.0;
|
||||
writer.writeInt32LE((latitude * 1e6).round());
|
||||
|
||||
// Longitude
|
||||
final longitude = lon ?? 0.0;
|
||||
writer.writeInt32LE((longitude * 1e6).round());
|
||||
}
|
||||
|
||||
if (lastModified != null) {
|
||||
// Last modified
|
||||
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
|
||||
writer.writeUInt32LE(lastModifiedTimestamp);
|
||||
}
|
||||
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
@@ -570,9 +757,13 @@ Uint8List buildGetContactByKeyFrame(Uint8List pubKey) {
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_GET_RADIO_SETTINGS frame
|
||||
Uint8List buildGetRadioSettingsFrame() {
|
||||
return Uint8List.fromList([cmdGetRadioSettings]);
|
||||
//Build CMD_GET_CUSTOM_VARS frame
|
||||
Uint8List buildGetCustomVarsFrame() {
|
||||
return Uint8List.fromList([cmdGetCustomVar]);
|
||||
}
|
||||
|
||||
Uint8List buildGetAutoAddFlagsFrame() {
|
||||
return Uint8List.fromList([cmdGetAutoAddConfig]);
|
||||
}
|
||||
|
||||
// Calculate LoRa airtime for a packet
|
||||
@@ -598,9 +789,11 @@ int calculateLoRaAirtime({
|
||||
final crc = 1; // CRC enabled
|
||||
final de = lowDataRateOptimize ? 1 : 0;
|
||||
|
||||
final numerator = 8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes;
|
||||
final numerator =
|
||||
8 * payloadBytes - 4 * spreadingFactor + 28 + 16 * crc - headerBytes;
|
||||
final denominator = 4 * (spreadingFactor - 2 * de);
|
||||
var payloadSymbols = 8 + ((numerator / denominator).ceil()) * (codingRate + 4);
|
||||
var payloadSymbols =
|
||||
8 + ((numerator / denominator).ceil()) * (codingRate + 4);
|
||||
|
||||
if (payloadSymbols < 0) {
|
||||
payloadSymbols = 8;
|
||||
@@ -647,7 +840,8 @@ Uint8List buildSendCliCommandFrame(
|
||||
int attempt = 0,
|
||||
int? timestampSeconds,
|
||||
}) {
|
||||
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
final timestamp =
|
||||
timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTxtMsg);
|
||||
writer.writeByte(txtTypeCliData);
|
||||
@@ -661,10 +855,7 @@ Uint8List buildSendCliCommandFrame(
|
||||
|
||||
// Build a telemetry request frame
|
||||
// Format: [cmd][pub_key x32][payload]
|
||||
Uint8List buildSendBinaryReq(
|
||||
Uint8List repeaterPubKey, {
|
||||
Uint8List? payload,
|
||||
}) {
|
||||
Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendBinaryReq);
|
||||
writer.writeBytes(repeaterPubKey);
|
||||
@@ -672,4 +863,99 @@ Uint8List buildSendBinaryReq(
|
||||
writer.writeBytes(payload);
|
||||
}
|
||||
return writer.toBytes();
|
||||
}
|
||||
}
|
||||
|
||||
//Build a trace request frame
|
||||
//[cmd][tag x4][auth x4][flag][payload]
|
||||
Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTracePath);
|
||||
writer.writeUInt32LE(tag);
|
||||
writer.writeUInt32LE(auth);
|
||||
writer.writeByte(flag);
|
||||
if (payload != null && payload.isNotEmpty) {
|
||||
writer.writeBytes(payload);
|
||||
}
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build a export contact frame
|
||||
// [cmd][pub_key x32 / if empty exports your contact info]
|
||||
Uint8List buildExportContactFrame(Uint8List pubKey) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdExportContact);
|
||||
writer.writeBytes(pubKey);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build a import contact frame
|
||||
// [cmd][contact_frame x98+]
|
||||
Uint8List buildImportContactFrame(Uint8List contactFrame) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdImportContact);
|
||||
writer.writeBytes(contactFrame);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build a export contact frame
|
||||
// [cmd][pub_key x32]
|
||||
Uint8List buildZeroHopContact(Uint8List pubKey) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdShareContact);
|
||||
writer.writeBytes(pubKey);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SET_OTHER_PARAMS frame
|
||||
// Format: [cmd][allowTelemetryFlags][advertLocationPolicy][multiAcks]
|
||||
Uint8List buildSetOtherParamsFrame(
|
||||
int allowTelemetryFlags,
|
||||
int advertLocationPolicy,
|
||||
int multiAcks,
|
||||
) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetOtherParams);
|
||||
//Going forward the app will just set Auto Add Contacts to disabled, and use the filter flags
|
||||
//Allow Auto Add Contacts use inverted logic (0x01 = disabled, 0x00 = enabled).
|
||||
writer.writeByte(0x01);
|
||||
writer.writeByte(allowTelemetryFlags); // Allow Telemetry Flags
|
||||
writer.writeByte(advertLocationPolicy); // Advertisement Location Policy
|
||||
writer.writeByte(multiAcks); // Multi Acknowledgements
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
// Build CMD_SET_AUTO_ADD_CONFIG frame
|
||||
// Format: [cmd][flags]
|
||||
Uint8List buildSetAutoAddConfigFrame({
|
||||
required bool autoAddChat,
|
||||
required bool autoAddRepeater,
|
||||
required bool autoAddRoomServer,
|
||||
required bool autoAddSensor,
|
||||
required bool overwriteOldest,
|
||||
}) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSetAutoAddConfig);
|
||||
int flags = 0;
|
||||
if (autoAddChat) flags |= autoAddChatFlag;
|
||||
if (autoAddRepeater) flags |= autoAddRepeaterFlag;
|
||||
if (autoAddRoomServer) flags |= autoAddRoomServerFlag;
|
||||
if (autoAddSensor) flags |= autoAddSensorFlag;
|
||||
if (overwriteOldest) flags |= autoAddOverwriteOldestFlag;
|
||||
writer.writeByte(flags);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
//Build CMD_SEND_TELEMETRY_REQ
|
||||
// Format: [cmd][reserved x3][pub_key? x32]
|
||||
Uint8List buildSendTelemetryReq(Uint8List? pubKey) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendTelemetryReq);
|
||||
|
||||
if (pubKey != null && pubKey.length == pubKeySize) {
|
||||
writer.writeBytes(Uint8List(3)); // reserved bytes
|
||||
writer.writeBytes(pubKey);
|
||||
} else {
|
||||
writer.writeBytes(Uint8List(4)); // reserved bytes
|
||||
}
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
+179
-163
@@ -1,4 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:meshcore_open/utils/app_logger.dart';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class CayenneLpp {
|
||||
@@ -26,9 +28,11 @@ class CayenneLpp {
|
||||
static const int lppUnixTime = 133; // 4 bytes, unsigned
|
||||
static const int lppGyrometer = 134; // 2 bytes per axis, 0.01 °/s
|
||||
static const int lppColour = 135; // 1 byte per RGB Color
|
||||
static const int lppGps = 136; // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01 meter
|
||||
static const int lppGps =
|
||||
136; // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01 meter
|
||||
static const int lppSwitch = 142; // 1 byte, 0/1
|
||||
static const int lppPolyline = 240; // 1 byte size, 1 byte delta factor, 3 byte lon/lat 0.0001° * factor, n (size-8) bytes deltas
|
||||
static const int lppPolyline =
|
||||
240; // 1 byte size, 1 byte delta factor, 3 byte lon/lat 0.0001° * factor, n (size-8) bytes deltas
|
||||
|
||||
final BufferWriter _writer = BufferWriter();
|
||||
|
||||
@@ -82,180 +86,192 @@ class CayenneLpp {
|
||||
static List<Map<String, dynamic>> parse(Uint8List bytes) {
|
||||
final buffer = BufferReader(bytes);
|
||||
final telemetry = <Map<String, dynamic>>[];
|
||||
try {
|
||||
while (buffer.remaining >= 2) {
|
||||
final channel = buffer.readUInt8();
|
||||
final type = buffer.readUInt8();
|
||||
|
||||
while (buffer.remaining >= 2) {
|
||||
final channel = buffer.readUInt8();
|
||||
final type = buffer.readUInt8();
|
||||
if (channel == 0 && type == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (channel == 0 && type == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case lppGenericSensor:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt32BE(),
|
||||
});
|
||||
break;
|
||||
case lppLuminosity:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppPresence:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8(),
|
||||
});
|
||||
break;
|
||||
case lppTemperature:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE() / 10,
|
||||
});
|
||||
break;
|
||||
case lppRelativeHumidity:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8() / 2,
|
||||
});
|
||||
break;
|
||||
case lppBarometricPressure:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE() / 10,
|
||||
});
|
||||
break;
|
||||
case lppVoltage:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE() / 100,
|
||||
});
|
||||
break;
|
||||
case lppCurrent:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE() / 1000,
|
||||
});
|
||||
break;
|
||||
case lppPercentage:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8(),
|
||||
});
|
||||
break;
|
||||
case lppConcentration:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppPower:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppGps:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': {
|
||||
'latitude': buffer.readInt24BE() / 10000,
|
||||
'longitude': buffer.readInt24BE() / 10000,
|
||||
'altitude': buffer.readInt24BE() / 100,
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return telemetry;
|
||||
switch (type) {
|
||||
case lppGenericSensor:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt32BE(),
|
||||
});
|
||||
break;
|
||||
case lppLuminosity:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppPresence:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8(),
|
||||
});
|
||||
break;
|
||||
case lppTemperature:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE() / 10,
|
||||
});
|
||||
break;
|
||||
case lppRelativeHumidity:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8() / 2,
|
||||
});
|
||||
break;
|
||||
case lppBarometricPressure:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE() / 10,
|
||||
});
|
||||
break;
|
||||
case lppVoltage:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE() / 100,
|
||||
});
|
||||
break;
|
||||
case lppCurrent:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE() / 1000,
|
||||
});
|
||||
break;
|
||||
case lppPercentage:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8(),
|
||||
});
|
||||
break;
|
||||
case lppConcentration:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppPower:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppGps:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': {
|
||||
'latitude': buffer.readInt24BE() / 10000,
|
||||
'longitude': buffer.readInt24BE() / 10000,
|
||||
'altitude': buffer.readInt24BE() / 100,
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return telemetry;
|
||||
}
|
||||
}
|
||||
return telemetry;
|
||||
} catch (e) {
|
||||
// Handle parsing errors, possibly due to malformed data
|
||||
appLogger.error('Error parsing Cayenne LPP data: $e');
|
||||
// Return any telemetry parsed so far to preserve partial data
|
||||
return telemetry;
|
||||
}
|
||||
|
||||
return telemetry;
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> parseByChannel(Uint8List bytes) {
|
||||
final buffer = BufferReader(bytes);
|
||||
final Map<int, Map<String, dynamic>> channels = {};
|
||||
try {
|
||||
while (buffer.remaining >= 2) {
|
||||
final channel = buffer.readUInt8();
|
||||
final type = buffer.readUInt8();
|
||||
|
||||
while (buffer.remaining >= 2) {
|
||||
final channel = buffer.readUInt8();
|
||||
final type = buffer.readUInt8();
|
||||
// Optional: stop on padding (00 00)
|
||||
if (channel == 0 && type == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Optional: stop on padding (00 00)
|
||||
if (channel == 0 && type == 0) {
|
||||
break;
|
||||
final channelData = channels.putIfAbsent(
|
||||
channel,
|
||||
() => {'channel': channel, 'values': <String, dynamic>{}},
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case lppGenericSensor:
|
||||
channelData['values']['generic'] = buffer.readUInt32BE();
|
||||
break;
|
||||
case lppLuminosity:
|
||||
channelData['values']['luminosity'] = buffer.readUInt16BE();
|
||||
break;
|
||||
case lppPresence:
|
||||
channelData['values']['presence'] = buffer.readUInt8() != 0;
|
||||
break;
|
||||
case lppTemperature:
|
||||
channelData['values']['temperature'] = buffer.readInt16BE() / 10.0;
|
||||
break;
|
||||
case lppRelativeHumidity:
|
||||
channelData['values']['humidity'] = buffer.readUInt8() / 2.0;
|
||||
break;
|
||||
case lppBarometricPressure:
|
||||
channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0;
|
||||
break;
|
||||
case lppVoltage:
|
||||
channelData['values']['voltage'] = buffer.readInt16BE() / 100.0;
|
||||
break;
|
||||
case lppCurrent:
|
||||
channelData['values']['current'] = buffer.readInt16BE() / 1000.0;
|
||||
break;
|
||||
case lppPercentage:
|
||||
channelData['values']['percentage'] = buffer.readUInt8();
|
||||
break;
|
||||
case lppConcentration:
|
||||
channelData['values']['concentration'] = buffer.readUInt16BE();
|
||||
break;
|
||||
case lppPower:
|
||||
channelData['values']['power'] = buffer.readUInt16BE();
|
||||
break;
|
||||
case lppGps:
|
||||
channelData['values']['gps'] = {
|
||||
'latitude': buffer.readInt24BE() / 10000.0,
|
||||
'longitude': buffer.readInt24BE() / 10000.0,
|
||||
'altitude': buffer.readInt24BE() / 100.0,
|
||||
};
|
||||
break;
|
||||
// Add more types as needed...
|
||||
default:
|
||||
//Stopped parsing to avoid misalignment
|
||||
return channels.values.toList();
|
||||
}
|
||||
}
|
||||
|
||||
final channelData = channels.putIfAbsent(channel, () => {
|
||||
'channel': channel,
|
||||
'values': <String, dynamic>{},
|
||||
});
|
||||
|
||||
switch (type) {
|
||||
case lppGenericSensor:
|
||||
channelData['values']['generic'] = buffer.readUInt32BE();
|
||||
break;
|
||||
case lppLuminosity:
|
||||
channelData['values']['luminosity'] = buffer.readUInt16BE();
|
||||
break;
|
||||
case lppPresence:
|
||||
channelData['values']['presence'] = buffer.readUInt8() != 0;
|
||||
break;
|
||||
case lppTemperature:
|
||||
channelData['values']['temperature'] = buffer.readInt16BE() / 10.0;
|
||||
break;
|
||||
case lppRelativeHumidity:
|
||||
channelData['values']['humidity'] = buffer.readUInt8() / 2.0;
|
||||
break;
|
||||
case lppBarometricPressure:
|
||||
channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0;
|
||||
break;
|
||||
case lppVoltage:
|
||||
channelData['values']['voltage'] = buffer.readInt16BE() / 100.0;
|
||||
break;
|
||||
case lppCurrent:
|
||||
channelData['values']['current'] = buffer.readInt16BE() / 1000.0;
|
||||
break;
|
||||
case lppPercentage:
|
||||
channelData['values']['percentage'] = buffer.readUInt8();
|
||||
break;
|
||||
case lppConcentration:
|
||||
channelData['values']['concentration'] = buffer.readUInt16BE();
|
||||
break;
|
||||
case lppPower:
|
||||
channelData['values']['power'] = buffer.readUInt16BE();
|
||||
break;
|
||||
case lppGps:
|
||||
channelData['values']['gps'] = {
|
||||
'latitude': buffer.readInt24BE() / 10000.0,
|
||||
'longitude': buffer.readInt24BE() / 10000.0,
|
||||
'altitude': buffer.readInt24BE() / 100.0,
|
||||
};
|
||||
break;
|
||||
// Add more types as needed...
|
||||
default:
|
||||
// Unknown type: skip or handle error?
|
||||
continue;
|
||||
}
|
||||
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
|
||||
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
|
||||
return channelsOut;
|
||||
} catch (e) {
|
||||
// Handle parsing errors, possibly due to malformed data
|
||||
appLogger.error('Error parsing Cayenne LPP data: $e');
|
||||
return <
|
||||
Map<String, dynamic>
|
||||
>[]; // Return an empty list on error to avoid crashing the app
|
||||
}
|
||||
|
||||
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
|
||||
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
|
||||
return channelsOut;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatScrollController extends ScrollController {
|
||||
final ValueNotifier<bool> showJumpToBottom = ValueNotifier(false);
|
||||
VoidCallback? onScrollNearTop;
|
||||
|
||||
static const _bottomThreshold = 100.0;
|
||||
static const _topThreshold = 50.0;
|
||||
|
||||
ChatScrollController() {
|
||||
addListener(_handleScroll);
|
||||
}
|
||||
|
||||
void _handleScroll() {
|
||||
if (!hasClients) return;
|
||||
final pos = position;
|
||||
|
||||
// With reverse: true, position 0 is bottom, maxScrollExtent is top
|
||||
// Show jump button when scrolled away from bottom (position > threshold)
|
||||
final isAtBottom = pos.pixels <= _bottomThreshold;
|
||||
if (showJumpToBottom.value == isAtBottom) {
|
||||
showJumpToBottom.value = !isAtBottom;
|
||||
}
|
||||
|
||||
// Pagination trigger when scrolled near top (maxScrollExtent)
|
||||
if (pos.pixels >= pos.maxScrollExtent - _topThreshold) {
|
||||
onScrollNearTop?.call();
|
||||
}
|
||||
}
|
||||
|
||||
void jumpToBottom() {
|
||||
if (hasClients && position.maxScrollExtent > 0) {
|
||||
animateTo(
|
||||
0, // With reverse: true, position 0 is bottom
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void handleKeyboardOpen() {
|
||||
// Simple: just scroll to bottom when keyboard opens
|
||||
if (hasClients) {
|
||||
animateTo(
|
||||
0, // With reverse: true, position 0 is bottom
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void scrollToBottomIfAtBottom() {
|
||||
// Only scroll if jump button is NOT showing (i.e., already at bottom)
|
||||
if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
|
||||
animateTo(
|
||||
0, // With reverse: true, position 0 is bottom
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
showJumpToBottom.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
class LinkHandler {
|
||||
static Future<void> handleLinkTap(BuildContext context, String url) async {
|
||||
// Show confirmation dialog
|
||||
final shouldOpen = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.chat_openLink),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.chat_openLinkConfirmation,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: SelectableText(
|
||||
url,
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(context.l10n.common_cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.chat_open),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (shouldOpen != true) return;
|
||||
|
||||
// Launch URL
|
||||
try {
|
||||
final uri = Uri.parse(url);
|
||||
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.chat_couldNotOpenLink(url)),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.chat_invalidLink),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,68 @@
|
||||
class ReactionInfo {
|
||||
final String targetMessageId;
|
||||
final String emoji;
|
||||
final String? reactionKey; // Lightweight key for deduplication: timestamp_senderPrefix
|
||||
import '../widgets/emoji_picker.dart';
|
||||
|
||||
ReactionInfo({
|
||||
required this.targetMessageId,
|
||||
required this.emoji,
|
||||
this.reactionKey,
|
||||
});
|
||||
class ReactionInfo {
|
||||
final String targetHash;
|
||||
final String emoji;
|
||||
|
||||
ReactionInfo({required this.targetHash, required this.emoji});
|
||||
}
|
||||
|
||||
class ReactionHelper {
|
||||
/// Parse reaction format: r:[messageId]:[emoji]
|
||||
/// Supports both old format (full messageId) and new format (timestamp_senderPrefix)
|
||||
static List<String>? _cachedEmojis;
|
||||
|
||||
/// Combined list of all reaction emojis in fixed order.
|
||||
/// Order must stay stable for index compatibility.
|
||||
static List<String> get reactionEmojis {
|
||||
return _cachedEmojis ??= [
|
||||
...EmojiPicker.quickEmojis,
|
||||
...EmojiPicker.smileys,
|
||||
...EmojiPicker.gestures,
|
||||
...EmojiPicker.hearts,
|
||||
...EmojiPicker.objects,
|
||||
];
|
||||
}
|
||||
|
||||
/// Convert emoji to 2-char hex index. Returns null if emoji not in list.
|
||||
static String? emojiToIndex(String emoji) {
|
||||
final idx = reactionEmojis.indexOf(emoji);
|
||||
if (idx < 0) return null;
|
||||
return idx.toRadixString(16).padLeft(2, '0');
|
||||
}
|
||||
|
||||
/// Convert 2-char hex index to emoji. Returns null if invalid index.
|
||||
static String? indexToEmoji(String hexIndex) {
|
||||
final idx = int.tryParse(hexIndex, radix: 16);
|
||||
if (idx == null || idx < 0 || idx >= reactionEmojis.length) return null;
|
||||
return reactionEmojis[idx];
|
||||
}
|
||||
|
||||
/// Compute a 4-char hex hash for a message reaction.
|
||||
/// Hash input: timestampSeconds + [senderName] + first 5 chars of text
|
||||
/// For 1:1 chats, senderName can be null (sender is implicit).
|
||||
static String computeReactionHash(
|
||||
int timestampSeconds,
|
||||
String? senderName,
|
||||
String text,
|
||||
) {
|
||||
final first5 = text.length >= 5 ? text.substring(0, 5) : text;
|
||||
final input = senderName != null
|
||||
? '$timestampSeconds$senderName$first5'
|
||||
: '$timestampSeconds$first5';
|
||||
// Use hashCode and take lower 16 bits, format as 4 hex chars
|
||||
final hash = input.hashCode & 0xFFFF;
|
||||
return hash.toRadixString(16).padLeft(4, '0');
|
||||
}
|
||||
|
||||
/// Parse reaction format: r:HASH:INDEX (where INDEX is 2-char hex emoji index)
|
||||
/// Returns null if text is not a valid reaction format
|
||||
static ReactionInfo? parseReaction(String text) {
|
||||
final regex = RegExp(r'^r:([^:]+):(.+)$');
|
||||
final regex = RegExp(r'^r:([0-9a-f]{4}):([0-9a-f]{2})$');
|
||||
final match = regex.firstMatch(text);
|
||||
if (match == null) return null;
|
||||
|
||||
final targetId = match.group(1)!;
|
||||
final emoji = match.group(2)!;
|
||||
final emoji = indexToEmoji(match.group(2)!);
|
||||
if (emoji == null) return null;
|
||||
|
||||
// Extract reaction key for deduplication
|
||||
// If targetId is in new format (timestamp_senderPrefix), use it directly
|
||||
// Otherwise, extract timestamp from old format (timestamp_nameHash_textHash)
|
||||
String? reactionKey;
|
||||
if (targetId.contains('_')) {
|
||||
final parts = targetId.split('_');
|
||||
if (parts.length >= 2) {
|
||||
// New format: timestamp_senderPrefix, or old format with at least timestamp
|
||||
reactionKey = '${parts[0]}_${parts[1]}';
|
||||
}
|
||||
}
|
||||
|
||||
return ReactionInfo(
|
||||
targetMessageId: targetId,
|
||||
emoji: emoji,
|
||||
reactionKey: reactionKey,
|
||||
);
|
||||
}
|
||||
|
||||
/// Generate a lightweight reaction key for a message
|
||||
/// Format: r:[timestamp]_[senderPrefix]:[emoji]
|
||||
static String buildReactionText(String timestamp, String senderPrefix, String emoji) {
|
||||
return 'r:${timestamp}_$senderPrefix:$emoji';
|
||||
}
|
||||
|
||||
/// Extract sender prefix from public key hex (first 8 chars)
|
||||
static String getSenderPrefix(String senderKeyHex) {
|
||||
return senderKeyHex.substring(0, 8);
|
||||
return ReactionInfo(targetHash: match.group(1)!, emoji: emoji);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-6
@@ -262,8 +262,9 @@ class Smaz {
|
||||
".com",
|
||||
];
|
||||
|
||||
static final List<Uint8List> _rcbBytes =
|
||||
_rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false);
|
||||
static final List<Uint8List> _rcbBytes = _rcb
|
||||
.map((s) => Uint8List.fromList(ascii.encode(s)))
|
||||
.toList(growable: false);
|
||||
static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) {
|
||||
return entry.length > maxLen ? entry.length : maxLen;
|
||||
});
|
||||
@@ -358,24 +359,32 @@ class Smaz {
|
||||
final code = input[index];
|
||||
if (code == _verbatimSingle) {
|
||||
if (index + 1 >= input.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim byte.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim byte.',
|
||||
);
|
||||
}
|
||||
out.addByte(input[index + 1]);
|
||||
index += 2;
|
||||
} else if (code == _verbatimRun) {
|
||||
if (index + 1 >= input.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim length.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim length.',
|
||||
);
|
||||
}
|
||||
final len = input[index + 1] + 1;
|
||||
final end = index + 2 + len;
|
||||
if (end > input.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: truncated verbatim run.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: truncated verbatim run.',
|
||||
);
|
||||
}
|
||||
out.add(input.sublist(index + 2, end));
|
||||
index = end;
|
||||
} else {
|
||||
if (code >= _rcbBytes.length) {
|
||||
throw const FormatException('Invalid SMAZ stream: code out of range.');
|
||||
throw const FormatException(
|
||||
'Invalid SMAZ stream: code out of range.',
|
||||
);
|
||||
}
|
||||
out.add(_rcbBytes[code]);
|
||||
index += 1;
|
||||
|
||||
@@ -8,7 +8,10 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
|
||||
const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
|
||||
TextEditingValue formatEditUpdate(
|
||||
TextEditingValue oldValue,
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
if (maxBytes <= 0) return oldValue;
|
||||
final bytes = utf8.encode(newValue.text);
|
||||
if (bytes.length <= maxBytes) return newValue;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
|
||||
class LosIcon extends StatelessWidget {
|
||||
final double size;
|
||||
final Color? color;
|
||||
|
||||
const LosIcon({super.key, this.size = 24, this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final iconTheme = IconTheme.of(context);
|
||||
final iconColor =
|
||||
color ??
|
||||
iconTheme.color ??
|
||||
theme.iconTheme.color ??
|
||||
theme.colorScheme.onSurface;
|
||||
|
||||
return Icon(Symbols.elevation, size: size, color: iconColor);
|
||||
}
|
||||
}
|
||||
+591
-6
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"channels_channelDeleteFailed": "Неуспешно изтриване на канала \"{name}\"",
|
||||
"@channels_channelDeleteFailed": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@@locale": "bg",
|
||||
"appTitle": "MeshCore Open",
|
||||
"nav_contacts": "Контакти",
|
||||
@@ -131,9 +139,6 @@
|
||||
"settings_infoContactsCount": "Брой контакти",
|
||||
"settings_infoChannelCount": "Брой канали",
|
||||
"settings_presets": "Предварителни настройки",
|
||||
"settings_preset915Mhz": "915 MHz",
|
||||
"settings_preset868Mhz": "868 MHz",
|
||||
"settings_preset433Mhz": "433 MHz",
|
||||
"settings_frequency": "Честота (MHz)",
|
||||
"settings_frequencyHelper": "300.0 - 2500.0",
|
||||
"settings_frequencyInvalid": "Невалидна честота (300-2500 MHz)",
|
||||
@@ -143,8 +148,6 @@
|
||||
"settings_txPower": "TX Мощност (dBm)",
|
||||
"settings_txPowerHelper": "0 - 22",
|
||||
"settings_txPowerInvalid": "Невалидна мощност на TX (0-22 dBm)",
|
||||
"settings_longRange": "Дълъг обхват",
|
||||
"settings_fastSpeed": "Бърза скорост",
|
||||
"settings_error": "Грешка: {message}",
|
||||
"@settings_error": {
|
||||
"placeholders": {
|
||||
@@ -282,6 +285,7 @@
|
||||
"contacts_newGroup": "Нова група",
|
||||
"contacts_groupName": "Група",
|
||||
"contacts_groupNameRequired": "Името на групата е задължително.",
|
||||
"contacts_groupNameReserved": "Това име на група е запазено",
|
||||
"contacts_groupAlreadyExists": "Групата \"{name}\" вече съществува.",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
@@ -339,6 +343,8 @@
|
||||
"channels_publicChannel": "Публичен канал",
|
||||
"channels_privateChannel": "Частен канал",
|
||||
"channels_editChannel": "Редактирай канал",
|
||||
"channels_muteChannel": "Заглуши канала",
|
||||
"channels_unmuteChannel": "Включи известията на канала",
|
||||
"channels_deleteChannel": "Изтрий канала",
|
||||
"channels_deleteChannelConfirm": "Изтрий \"{name}\"? Това не може да бъде отменено.",
|
||||
"@channels_deleteChannelConfirm": {
|
||||
@@ -604,6 +610,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_openLink": "Отваряне на връзката?",
|
||||
"chat_openLinkConfirmation": "Искате ли да отворите тази връзка в браузъра си?",
|
||||
"chat_open": "Отвори",
|
||||
"chat_couldNotOpenLink": "Не можа да се отвори връзката: {url}",
|
||||
"@chat_couldNotOpenLink": {
|
||||
"placeholders": {
|
||||
"url": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_invalidLink": "Невалиден формат на връзката",
|
||||
"map_title": "Карта на възлите",
|
||||
"map_noNodesWithLocation": "Няма възли с данни за местоположение.",
|
||||
"map_nodesNeedGps": "Възлагат се възлозите да споделят техните GPS координати,\nза да се появят на картата.",
|
||||
@@ -821,6 +839,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"login_failedMessage": "Входът не беше успешен. Или паролата е грешна, или повторителят е недостъпен.",
|
||||
"common_reload": "Презареди",
|
||||
"common_clear": "Изчисти",
|
||||
"path_currentPath": "Текущ път: {path}",
|
||||
@@ -1335,5 +1354,571 @@
|
||||
"listFilter_repeaters": "Повторители",
|
||||
"listFilter_roomServers": "Сървъри на стая",
|
||||
"listFilter_unreadOnly": "Само непрочетените",
|
||||
"listFilter_newGroup": "Нова група"
|
||||
"listFilter_newGroup": "Нова група",
|
||||
"@neighbors_errorLoading": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_neighborsSubtitle": "Преглед на съседни възли с нулев скок.",
|
||||
"repeater_neighbors": "Съседи",
|
||||
"neighbors_receivedData": "Получени данни за съседи",
|
||||
"neighbors_requestTimedOut": "Съседите поискат изтичане на време.",
|
||||
"neighbors_errorLoading": "Грешка при зареждане на съседи: {error}",
|
||||
"neighbors_repeatersNeighbors": "Повторители Съседи",
|
||||
"neighbors_noData": "Няма налични данни за съседи.",
|
||||
"channels_createPrivateChannel": "Създай Частен Канал",
|
||||
"channels_joinPrivateChannel": "Присъедини се към Частен Канал",
|
||||
"channels_createPrivateChannelDesc": "Защитено с таен ключ.",
|
||||
"channels_joinPrivateChannelDesc": "Ръчно въведете таен ключ.",
|
||||
"channels_joinPublicChannel": "Присъединете се към Публичния канал",
|
||||
"channels_joinPublicChannelDesc": "Всеки може да се присъедини към този канал.",
|
||||
"channels_joinHashtagChannel": "Присъедини се към Хаштаг Канал",
|
||||
"channels_joinHashtagChannelDesc": "Всеки може да се присъедини към хаштаговите канали.",
|
||||
"channels_scanQrCode": "Сканирайте QR код",
|
||||
"channels_scanQrCodeComingSoon": "Ще излезе скоро",
|
||||
"channels_enterHashtag": "Въведете хаштаг",
|
||||
"channels_hashtagHint": "напр. #отбор",
|
||||
"@neighbors_unknownContact": {
|
||||
"placeholders": {
|
||||
"pubkey": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@neighbors_heardAgo": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"neighbors_heardAgo": "Слушано преди {time}.",
|
||||
"neighbors_unknownContact": "Неизвестна {pubkey}",
|
||||
"settings_locationIntervalSec": "Интервал за GPS (Секунди)",
|
||||
"settings_locationGPSEnable": "Активиране на GPS",
|
||||
"settings_locationGPSEnableSubtitle": "Активирайте автоматичното актуализиране на местоположението чрез GPS.",
|
||||
"settings_locationIntervalInvalid": "Интервалът трябва да бъде поне 60 секунди и по-малко от 86400 секунди.",
|
||||
"room_management": "Управление на сървъра за стая",
|
||||
"contacts_manageRoom": "Управление на сървър за стая",
|
||||
"@community_joinConfirmation": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_created": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_joined": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_qrInstructions": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_alreadyMemberMessage": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleteConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleted": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_forCommunity": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_title": "Общност",
|
||||
"common_ok": "Добре",
|
||||
"community_createDesc": "Създайте нова общност и я споделете чрез QR код.",
|
||||
"community_create": "Създай общност",
|
||||
"community_joinTitle": "Присъедини се към общността",
|
||||
"community_joinConfirmation": "Искате ли да се присъедините към общността \"{name}\"?",
|
||||
"community_scanQr": "Сканирайте QR кода на общността",
|
||||
"community_scanInstructions": "Насочете камерата към QR код на общността",
|
||||
"community_showQr": "Покажи QR код",
|
||||
"community_publicChannel": "Обществено общност",
|
||||
"community_hashtagChannel": "Хаштаг на общността",
|
||||
"community_name": "Име на общността",
|
||||
"community_enterName": "Въведете име на общността",
|
||||
"community_created": "Общността \"{name}\" е създадена",
|
||||
"community_joined": "Присъединено общност \"{name}\"",
|
||||
"community_qrTitle": "Споделяне в общността",
|
||||
"community_join": "Присъедини се",
|
||||
"community_qrInstructions": "Сканирайте този QR код, за да се присъедините към {name}.",
|
||||
"community_hashtagPrivacyHint": "Хаштаг каналите на общността са достъпни само за членове на общността",
|
||||
"community_invalidQrCode": "Невалиден QR код на общността",
|
||||
"community_alreadyMember": "Вече съм член",
|
||||
"community_alreadyMemberMessage": "Вие вече сте член на \"{name}\".",
|
||||
"community_addPublicChannel": "Добави публичен общностен канал",
|
||||
"community_addPublicChannelHint": "Автоматично добавете публичния канал за тази общност.",
|
||||
"community_noCommunities": "Няма присъединени общности още.",
|
||||
"community_scanOrCreate": "Сканирайте QR код или създайте общност, за да започнете.",
|
||||
"community_manageCommunities": "Управление на общности",
|
||||
"community_delete": "Напусни общността",
|
||||
"community_deleteConfirm": "Напускате \"{name}\"?",
|
||||
"community_deleteChannelsWarning": "Това ще изтрие също {count} канал(а) и техните съобщения.",
|
||||
"@community_deleteChannelsWarning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_deleted": "Остави общността \"{name}\"",
|
||||
"community_addHashtagChannel": "Добави общностен хаштаг",
|
||||
"community_addHashtagChannelDesc": "Добавете хаштаг канал за тази общност",
|
||||
"community_selectCommunity": "Изберете общност",
|
||||
"community_regularHashtag": "Обикновен хаштаг",
|
||||
"community_regularHashtagDesc": "Общ хаштаг (всеки може да се присъедини)",
|
||||
"community_communityHashtag": "Общностен хаштаг",
|
||||
"community_communityHashtagDesc": "Само за членове на общността",
|
||||
"community_forCommunity": "За {name}",
|
||||
"@community_regenerateSecretConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretRegenerated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretUpdated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_scanToUpdateSecret": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_regenerateSecretConfirm": "Регенерация на секретния ключ за \"{name}\"? Всички членове ще трябва да сканират новия QR код, за да продължат комуникацията.",
|
||||
"community_secretRegenerated": "Секретно презареждане за \"{name}\"",
|
||||
"community_regenerateSecret": "Регенерейрай секрет",
|
||||
"community_regenerate": "Регенерация",
|
||||
"community_updateSecret": "Актуализирай тайна",
|
||||
"community_scanToUpdateSecret": "Сканьорвайте новия QR код, за да актуализирате секрета за \"{name}\"",
|
||||
"community_secretUpdated": "Секретно обновено за \"{name}\"",
|
||||
"@contacts_pathTraceTo": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pathTrace_you": "Вие",
|
||||
"pathTrace_notAvailable": "Пътека за проследяване не е достъпна.",
|
||||
"contacts_pathTrace": "Пътен проследяване",
|
||||
"pathTrace_refreshTooltip": "Обнови Path Trace.",
|
||||
"pathTrace_failed": "Пътят за проследяване не успя.",
|
||||
"contacts_repeaterPing": "Пингване на повторителя",
|
||||
"contacts_repeaterPathTrace": "Трасировка до повторител",
|
||||
"contacts_ping": "Пинг",
|
||||
"contacts_chatTraceRoute": "Трасиране на път",
|
||||
"contacts_roomPathTrace": "Трасиране на път до съ",
|
||||
"contacts_roomPing": "Ping на сървъра на стаята",
|
||||
"contacts_pathTraceTo": "Проследи маршрут към {name}",
|
||||
"appSettings_languageUk": "Украински",
|
||||
"contacts_clipboardEmpty": "Клипборда е празна.",
|
||||
"contacts_invalidAdvertFormat": "Невалидни данни за контакт",
|
||||
"appSettings_languageRu": "Руски",
|
||||
"appSettings_enableMessageTracing": "Разрешаване на проследяване на съобщения",
|
||||
"appSettings_enableMessageTracingSubtitle": "Показване на подробни метаданни за маршрутизация и синхронизация за съобщения",
|
||||
"contacts_contactImported": "Контактът е импортиран.",
|
||||
"contacts_zeroHopAdvert": "Реклама без скок",
|
||||
"contacts_contactImportFailed": "Контактът не е успешно импортиран.",
|
||||
"contacts_floodAdvert": "Потопна реклама",
|
||||
"contacts_addContactFromClipboard": "Добави контакт от клипборда",
|
||||
"contacts_copyAdvertToClipboard": "Копирай обявата в клипборда",
|
||||
"contacts_ShareContact": "Копирай контакт в клипборда",
|
||||
"contacts_ShareContactZeroHop": "Сподели контакт чрез обява",
|
||||
"contacts_contactAdvertCopied": "Рекламата е копирана в клипборда.",
|
||||
"contacts_zeroHopContactAdvertFailed": "Неуспешно изпращане на контакт.",
|
||||
"contacts_zeroHopContactAdvertSent": "Изпратен контакт по обява.",
|
||||
"contacts_contactAdvertCopyFailed": "Копирането на обявата в клипборда не успя.",
|
||||
"notification_activityTitle": "Активност на MeshCore",
|
||||
"notification_messagesCount": "{count} {count, plural, =1{съобщение} other{съобщения}}",
|
||||
"notification_channelMessagesCount": "{count} {count, plural, =1{съобщение в канал} other{съобщения в канали}}",
|
||||
"notification_newNodesCount": "{count} {count, plural, =1{нов възел} other{нови възли}}",
|
||||
"notification_newTypeDiscovered": "Открит нов {contactType}",
|
||||
"notification_receivedNewMessage": "Получено ново съобщение",
|
||||
"settings_gpxExportContactsSubtitle": "Експортира спътници с местоположение в GPX файл.",
|
||||
"settings_gpxExportRepeatersSubtitle": "Изпраща повторители / roomserver с местоположение в GPX файл.",
|
||||
"settings_gpxExportAll": "Експортирай всички контакти в GPX",
|
||||
"settings_gpxExportAllSubtitle": "Експортира всички контакти с местоположение в файл GPX.",
|
||||
"settings_gpxExportRepeaters": "Експортиране на повтарящи се устройства / сървър на стаята до GPX",
|
||||
"settings_gpxExportContacts": "Експортирай спътници към GPX",
|
||||
"settings_gpxExportSuccess": "Успешно изlexport на файл GPX.",
|
||||
"settings_gpxExportNoContacts": "Няма контакти за изlexport.",
|
||||
"settings_gpxExportChat": "Местоположения на спътници",
|
||||
"settings_gpxExportError": "Възникна грешка при изнасяне.",
|
||||
"settings_gpxExportRepeatersRoom": "Местоположения на повторител и сървър на стаята",
|
||||
"settings_gpxExportNotAvailable": "Не е поддържан на вашето устройство/ОС",
|
||||
"settings_gpxExportAllContacts": "Местоположения на всички контакти",
|
||||
"settings_gpxExportShareText": "Картинни данни изнесени от meshcore-open",
|
||||
"settings_gpxExportShareSubject": "meshcore-open износ на данни за карта в формат GPX",
|
||||
"pathTrace_someHopsNoLocation": "Един или повече от хмелите липсва местоположение!",
|
||||
"map_pathTraceCancelled": "Отменен е следването на пътя.",
|
||||
"pathTrace_clearTooltip": "Изчисти пътя",
|
||||
"map_removeLast": "Премахни Последно",
|
||||
"map_runTrace": "Изпълни Път на Следване",
|
||||
"map_tapToAdd": "Натиснете върху възлите, за да ги добавите към пътя.",
|
||||
"scanner_bluetoothOff": "Bluetooth е изключен.",
|
||||
"scanner_enableBluetooth": "Активирайте Bluetooth",
|
||||
"scanner_bluetoothOffMessage": "Моля, активирайте Bluetooth, за да сканирате за устройства.",
|
||||
"scanner_chromeRequired": "Изисква се браузър Chrome",
|
||||
"scanner_chromeRequiredMessage": "Това уеб приложение изисква Google Chrome или браузър, базиран на Chromium, за поддръжка на Bluetooth.",
|
||||
"snrIndicator_lastSeen": "Последно видян",
|
||||
"snrIndicator_nearByRepeaters": "Близки повтарящи се устройства",
|
||||
"chat_ShowAllPaths": "Покажи всички пътища",
|
||||
"settings_clientRepeatSubtitle": "Позволете на това устройство да предава пакети към мрежата за други устройства.",
|
||||
"settings_clientRepeatFreqWarning": "За повторение извън мрежата са необходими честоти от 433, 869 или 918 MHz.",
|
||||
"settings_clientRepeat": "Без електричество – повторение",
|
||||
"settings_aboutOpenMeteoAttribution": "Данни за надморска височина на LOS: Open-Meteo (CC BY 4.0)",
|
||||
"appSettings_unitsTitle": "единици",
|
||||
"appSettings_unitsMetric": "Метрика (m / km)",
|
||||
"appSettings_unitsImperial": "Имперска (ft / mi)",
|
||||
"map_lineOfSight": "Линия на видимост",
|
||||
"map_losScreenTitle": "Линия на видимост",
|
||||
"losSelectStartEnd": "Изберете начални и крайни възли за LOS.",
|
||||
"losRunFailed": "Проверката на пряката видимост е неуспешна: {error}",
|
||||
"@losRunFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losClearAllPoints": "Изчистете всички точки",
|
||||
"losRunToViewElevationProfile": "Стартирайте LOS, за да видите профила на надморската височина",
|
||||
"losMenuTitle": "LOS меню",
|
||||
"losMenuSubtitle": "Докоснете възли или натиснете продължително карта за персонализирани точки",
|
||||
"losShowDisplayNodes": "Показване на възли на дисплея",
|
||||
"losCustomPoints": "Персонализирани точки",
|
||||
"losCustomPointLabel": "Персонализирано {index}",
|
||||
"@losCustomPointLabel": {
|
||||
"placeholders": {
|
||||
"index": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losPointA": "Точка А",
|
||||
"losPointB": "Точка Б",
|
||||
"losAntennaA": "Антена A: {value} {unit}",
|
||||
"@losAntennaA": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losAntennaB": "Антена B: {value} {unit}",
|
||||
"@losAntennaB": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losRun": "Стартирайте LOS",
|
||||
"losNoElevationData": "Няма данни за надморска височина",
|
||||
"losProfileClear": "{distance} {distanceUnit}, чист LOS, минимално разстояние {clearance} {heightUnit}",
|
||||
"@losProfileClear": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"clearance": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losProfileBlocked": "{distance} {distanceUnit}, блокиран от {obstruction} {heightUnit}",
|
||||
"@losProfileBlocked": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"obstruction": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losStatusChecking": "LOS: проверка...",
|
||||
"losStatusNoData": "LOS: няма данни",
|
||||
"losStatusSummary": "LOS: {clear}/{total} ясно, {blocked} блокирано, {unknown} неизвестно",
|
||||
"@losStatusSummary": {
|
||||
"placeholders": {
|
||||
"clear": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "int"
|
||||
},
|
||||
"unknown": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losErrorElevationUnavailable": "Няма налични данни за надморска височина за една или повече проби.",
|
||||
"losErrorInvalidInput": "Невалидни данни за точки/надморска височина за изчисляване на LOS.",
|
||||
"losRenameCustomPoint": "Преименувайте персонализирана точка",
|
||||
"losPointName": "Име на точката",
|
||||
"losShowPanelTooltip": "Показване на LOS панел",
|
||||
"losHidePanelTooltip": "Скриване на LOS панела",
|
||||
"losElevationAttribution": "Данни за надморска височина: Open-Meteo (CC BY 4.0)",
|
||||
"losLegendRadioHorizon": "Радиохоризонт",
|
||||
"losLegendLosBeam": "Линия на видимост",
|
||||
"losLegendTerrain": "Терен",
|
||||
"losFrequencyLabel": "Честота",
|
||||
"losFrequencyInfoTooltip": "Преглед на детайли за изчислението",
|
||||
"losFrequencyDialogTitle": "Изчисляване на радиохоризонта",
|
||||
"losFrequencyDialogDescription": "Започвайки от k={baselineK} при {baselineFreq} MHz, изчислението коригира k-фактора за текущата {frequencyMHz} MHz лента, която определя границата на извития радиохоризонт.",
|
||||
"@losFrequencyDialogDescription": {
|
||||
"description": "Explain how the calculation uses the baseline frequency and derived k-factor.",
|
||||
"placeholders": {
|
||||
"baselineK": {
|
||||
"type": "double"
|
||||
},
|
||||
"baselineFreq": {
|
||||
"type": "double"
|
||||
},
|
||||
"frequencyMHz": {
|
||||
"type": "double"
|
||||
},
|
||||
"kFactor": {
|
||||
"type": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
"listFilter_removeFromFavorites": "Премахване от списъка с любими",
|
||||
"listFilter_addToFavorites": "Добави към любими",
|
||||
"listFilter_favorites": "Любими",
|
||||
"@contacts_searchFavorites": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchUsers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRepeaters": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRoomServers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contacts_searchFavorites": "Търсене на {number}{str} любими...",
|
||||
"contacts_searchRoomServers": "Търсене на {number}{str} сървъри в стаята...",
|
||||
"contacts_unread": "Непрочетено",
|
||||
"contacts_searchRepeaters": "Търсене на {number}{str} повтарящи се...",
|
||||
"contacts_searchContactsNoNumber": "Търси контакти...",
|
||||
"contacts_searchUsers": "Търсене на {number}{str} потребители...",
|
||||
"contactsSettings_title": "Настройки на контактите",
|
||||
"contactsSettings_autoAddTitle": "Автоматично откриване",
|
||||
"contactsSettings_autoAddUsersTitle": "Автоматично добавяне на потребители",
|
||||
"contactsSettings_otherTitle": "Други настройки свързани с контакти",
|
||||
"settings_contactSettingsSubtitle": "Настройки за добавяне на контакти.",
|
||||
"settings_contactSettings": "Настройки за контакти",
|
||||
"contactsSettings_autoAddSensorsTitle": "Автоматично добавяне на датчици",
|
||||
"contactsSettings_autoAddRoomServersTitle": "Автоматично добавяне на сървъри на стаите",
|
||||
"contactsSettings_autoAddRoomServersSubtitle": "Позволи на спътника да добавя автоматично откритите сървъри на стаите.",
|
||||
"contactsSettings_autoAddRepeatersTitle": "Автоматично добавяне на повтарящи се елементи",
|
||||
"contactsSettings_autoAddUsersSubtitle": "Позволи на спътника да добавя автоматично откритите потребители.",
|
||||
"contactsSettings_autoAddRepeatersSubtitle": "Позволи на спътника да добавя автоматично откритите повтарящи се устройства.",
|
||||
"contactsSettings_autoAddSensorsSubtitle": "Позволи на спътника да добавя автоматично откритите датчици.",
|
||||
"contactsSettings_overwriteOldestTitle": "Премахни най-старото",
|
||||
"discoveredContacts_Title": "Открити контакти",
|
||||
"discoveredContacts_searchHint": "Търсене на открити контакти",
|
||||
"discoveredContacts_noMatching": "Няма съвпадащи контакти",
|
||||
"discoveredContacts_contactAdded": "Контакт добавен",
|
||||
"discoveredContacts_copyContact": "Копирай контакт в клипборда",
|
||||
"discoveredContacts_deleteContact": "Изтрий контакт",
|
||||
"discoveredContacts_addContact": "Добави контакт",
|
||||
"contactsSettings_overwriteOldestSubtitle": "Когато списъкът с контакти е пълен, най-старият неключов контакт ще бъде заменен.",
|
||||
"discoveredContacts_deleteContactAll": "Изтриване на Всички Открити Контакти",
|
||||
"discoveredContacts_deleteContactAllContent": "Сигурни ли сте, че искате да изтриете всички открити контакти?",
|
||||
"common_deleteAll": "Изтрий всичко",
|
||||
"map_guessedLocation": "Предполагано местоположение",
|
||||
"map_showGuessedLocations": "Покажете местоположенията на предположените възли.",
|
||||
"connectionChoiceUsbLabel": "USB",
|
||||
"usbScreenTitle": "Свържете се чрез USB",
|
||||
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||
"usbScreenSubtitle": "Изберете открития сериен уред и свържете директно към вашия MeshCore възел.",
|
||||
"usbScreenStatus": "Изберете USB устройство",
|
||||
"usbScreenNote": "USB серийната връзка е активна на поддържаните Android устройства и настолни платформи.",
|
||||
"usbScreenEmptyState": "Няма открити USB устройства. Включете едно и опитайте отново.",
|
||||
"usbErrorPermissionDenied": "Не беше разрешено достъпът през USB.",
|
||||
"usbErrorDeviceMissing": "Избраното USB устройство вече не е налично.",
|
||||
"usbErrorInvalidPort": "Изберете валитно USB устройство.",
|
||||
"usbErrorBusy": "Друг мол за свързване през USB вече е в процес на изпълнение.",
|
||||
"usbErrorNotConnected": "Няма свързано USB устройство.",
|
||||
"usbErrorOpenFailed": "Не успях да отворя избраното USB устройство.",
|
||||
"usbErrorConnectFailed": "Не успях да се свържа с избраното USB устройство.",
|
||||
"usbErrorUnsupported": "USB серийната комуникация не се поддържа на тази платформа.",
|
||||
"usbErrorAlreadyActive": "USB връзката вече е активирана.",
|
||||
"usbErrorNoDeviceSelected": "Няма избран USB устройство.",
|
||||
"usbErrorPortClosed": "USB връзката не е активна.",
|
||||
"usbFallbackDeviceName": "Устройство за четене на уеб серийни данни",
|
||||
"@usbConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"usbStatus_connecting": "Свързване към USB устройство...",
|
||||
"usbConnectionFailed": "Неуспешно свързване през USB: {error}",
|
||||
"usbStatus_notConnected": "Изберете USB устройство",
|
||||
"usbStatus_searching": "Търсене на USB устройства...",
|
||||
"usbErrorConnectTimedOut": "Връзката прекъсна. Уверете се, че устройството има софтуер за USB връзка.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpScreenTitle": "Свържете се чрез TCP",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpHostLabel": "IP адрес",
|
||||
"tcpPortLabel": "Пристанище",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Въведете крайната точка и свържете се.",
|
||||
"tcpStatus_connectingTo": "Свързване към {endpoint}...",
|
||||
"tcpErrorHostRequired": "Необходим е IP адрес.",
|
||||
"tcpErrorPortInvalid": "Портът трябва да бъде между 1 и 65535.",
|
||||
"tcpErrorUnsupported": "Транспортът чрез TCP не се поддържа на тази платформа.",
|
||||
"tcpErrorTimedOut": "Връзката TCP изтекла.",
|
||||
"tcpConnectionFailed": "Неуспешно е установено TCP връзката: {error}",
|
||||
"map_showDiscoveryContacts": "Покажи контакти за откриване",
|
||||
"map_setAsMyLocation": "Задайте като моя местоположение",
|
||||
"settings_denyAll": "Откажи всичко",
|
||||
"settings_allowAll": "Позволи всичко",
|
||||
"settings_allowByContact": "Позволи по флагове за контакт",
|
||||
"settings_privacy": "Настройки на поверителността",
|
||||
"settings_privacySettingsDescription": "Изберете каква информация устройството ви споделя с другите.",
|
||||
"settings_privacySubtitle": "Контролирайте каква информация се споделя.",
|
||||
"settings_telemetryBaseMode": "Базов режим на телеметрия",
|
||||
"settings_telemetryLocationMode": "Режим на местоположение на телеметрията",
|
||||
"settings_advertLocation": "Място на обявата",
|
||||
"settings_advertLocationSubtitle": "Включи местоположение в обявата",
|
||||
"contact_info": "Контактна информация",
|
||||
"settings_telemetryEnvironmentMode": "Режим на средата на телеметрията",
|
||||
"contact_telemetry": "Телеметрия",
|
||||
"contact_lastSeen": "Последно видян",
|
||||
"contact_clearChat": "Изчисти чата",
|
||||
"contact_teleBase": "Базата данни за телеметрия",
|
||||
"contact_settings": "Настройки за контакти",
|
||||
"contact_teleBaseSubtitle": "Позволи споделяне на ниво на батерията и основна телеметрия",
|
||||
"contact_teleEnv": "Среда на телеметрия",
|
||||
"contact_teleLocSubtitle": "Позволи споделяне на данни за местоположение",
|
||||
"contact_teleLoc": "Местоположение на телеметрията",
|
||||
"contact_teleEnvSubtitle": "Позволи споделяне на данни от средносферните датчици",
|
||||
"@settings_multiAck": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings_multiAck": "Мулти-потвърди: {value}",
|
||||
"settings_telemetryModeUpdated": "Режим на телеметрията е обновен"
|
||||
}
|
||||
|
||||
+770
-157
File diff suppressed because it is too large
Load Diff
+965
-147
File diff suppressed because it is too large
Load Diff
+624
-11
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"channels_channelDeleteFailed": "No se pudo eliminar el canal \"{name}\"",
|
||||
"@channels_channelDeleteFailed": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@@locale": "es",
|
||||
"appTitle": "MeshCore Open",
|
||||
"nav_contacts": "Contactos",
|
||||
@@ -131,9 +139,6 @@
|
||||
"settings_infoContactsCount": "Número de contactos",
|
||||
"settings_infoChannelCount": "Número de canales",
|
||||
"settings_presets": "Preajustes",
|
||||
"settings_preset915Mhz": "915 MHz",
|
||||
"settings_preset868Mhz": "868 MHz",
|
||||
"settings_preset433Mhz": "433 MHz",
|
||||
"settings_frequency": "Frecuencia (MHz)",
|
||||
"settings_frequencyHelper": "300,0 - 2500,0",
|
||||
"settings_frequencyInvalid": "Frecuencia inválida (300-2500 MHz)",
|
||||
@@ -143,8 +148,6 @@
|
||||
"settings_txPower": "TX Potencia (dBm)",
|
||||
"settings_txPowerHelper": "0 - 22",
|
||||
"settings_txPowerInvalid": "Potencia de TX inválida (0-22 dBm)",
|
||||
"settings_longRange": "Largo Alcance",
|
||||
"settings_fastSpeed": "Velocidad Rápida",
|
||||
"settings_error": "Error: {message}",
|
||||
"@settings_error": {
|
||||
"placeholders": {
|
||||
@@ -282,6 +285,7 @@
|
||||
"contacts_newGroup": "Nuevo Grupo",
|
||||
"contacts_groupName": "Nombre del grupo",
|
||||
"contacts_groupNameRequired": "El nombre del grupo es obligatorio",
|
||||
"contacts_groupNameReserved": "Este nombre de grupo está reservado",
|
||||
"contacts_groupAlreadyExists": "El grupo \"{name}\" ya existe",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
@@ -294,7 +298,7 @@
|
||||
"contacts_noContactsMatchFilter": "No hay contactos que coincidan con tu filtro",
|
||||
"contacts_noMembers": "No miembros",
|
||||
"contacts_lastSeenNow": "Última vez que se vio ahora",
|
||||
"contacts_lastSeenMinsAgo": "Última vez visto hace {minutes} minutos.",
|
||||
"contacts_lastSeenMinsAgo": "~ {minutes} min.",
|
||||
"@contacts_lastSeenMinsAgo": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
@@ -302,8 +306,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"contacts_lastSeenHourAgo": "Última vez que se vio hace 1 hora",
|
||||
"contacts_lastSeenHoursAgo": "Última vez visto hace {hours} horas.",
|
||||
"contacts_lastSeenHourAgo": "~ 1 hora",
|
||||
"contacts_lastSeenHoursAgo": "~ {hours} horas",
|
||||
"@contacts_lastSeenHoursAgo": {
|
||||
"placeholders": {
|
||||
"hours": {
|
||||
@@ -311,8 +315,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"contacts_lastSeenDayAgo": "Última vez que se vio hace 1 día",
|
||||
"contacts_lastSeenDaysAgo": "Última vez visto hace {days} días.",
|
||||
"contacts_lastSeenDayAgo": "~ 1 día",
|
||||
"contacts_lastSeenDaysAgo": "~ {days} días",
|
||||
"@contacts_lastSeenDaysAgo": {
|
||||
"placeholders": {
|
||||
"days": {
|
||||
@@ -339,6 +343,8 @@
|
||||
"channels_publicChannel": "Canal público",
|
||||
"channels_privateChannel": "Canal privado",
|
||||
"channels_editChannel": "Editar canal",
|
||||
"channels_muteChannel": "Silenciar canal",
|
||||
"channels_unmuteChannel": "Activar canal",
|
||||
"channels_deleteChannel": "Eliminar canal",
|
||||
"channels_deleteChannelConfirm": "Eliminar \"{name}\"? Esto no se puede deshacer.",
|
||||
"@channels_deleteChannelConfirm": {
|
||||
@@ -604,6 +610,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_openLink": "¿Abrir enlace?",
|
||||
"chat_openLinkConfirmation": "¿Quiere abrir este enlace en su navegador?",
|
||||
"chat_open": "Abrir",
|
||||
"chat_couldNotOpenLink": "No se pudo abrir el enlace: {url}",
|
||||
"@chat_couldNotOpenLink": {
|
||||
"placeholders": {
|
||||
"url": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_invalidLink": "Formato de enlace no válido",
|
||||
"map_title": "Mapa de Nodos",
|
||||
"map_noNodesWithLocation": "No hay nodos con datos de ubicación",
|
||||
"map_nodesNeedGps": "Los nodos necesitan compartir sus coordenadas GPS\npara aparecer en el mapa",
|
||||
@@ -821,6 +839,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"login_failedMessage": "Inicio fallido. La contraseña es incorrecta o el repetidor no está disponible.",
|
||||
"common_reload": "Recargar",
|
||||
"common_clear": "Borrar",
|
||||
"path_currentPath": "Ruta actual: {path}",
|
||||
@@ -1335,5 +1354,599 @@
|
||||
"listFilter_repeaters": "Repetidores",
|
||||
"listFilter_roomServers": "Servidores de la sala",
|
||||
"listFilter_unreadOnly": "Solo sin leer",
|
||||
"listFilter_newGroup": "Nuevo grupo"
|
||||
"listFilter_newGroup": "Nuevo grupo",
|
||||
"@neighbors_errorLoading": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_neighbors": "Vecinos",
|
||||
"repeater_neighborsSubtitle": "Ver vecinos de salto cero.",
|
||||
"neighbors_receivedData": "Recibidas Datos de Vecinos",
|
||||
"neighbors_requestTimedOut": "Los vecinos solicitan que se desconecte.",
|
||||
"neighbors_errorLoading": "Error al cargar vecinos: {error}",
|
||||
"neighbors_repeatersNeighbors": "Repetidores Vecinos",
|
||||
"neighbors_noData": "No hay datos de vecinos disponibles.",
|
||||
"channels_joinPrivateChannel": "Únete a un Canal Privado",
|
||||
"channels_createPrivateChannel": "Crear un Canal Privado",
|
||||
"channels_createPrivateChannelDesc": "Cifrado con una clave secreta.",
|
||||
"channels_joinPrivateChannelDesc": "Introducir manualmente una clave secreta.",
|
||||
"channels_joinPublicChannel": "Únete al Canal Público",
|
||||
"channels_joinPublicChannelDesc": "Cualquiera puede unirse a este canal.",
|
||||
"channels_joinHashtagChannel": "Únete a un Canal con Hashtag",
|
||||
"channels_joinHashtagChannelDesc": "Cualquiera puede unirse a los canales de hashtag.",
|
||||
"channels_scanQrCode": "Escanear un Código QR",
|
||||
"channels_scanQrCodeComingSoon": "Próximamente",
|
||||
"channels_enterHashtag": "Introducir hashtag",
|
||||
"channels_hashtagHint": "ej. #equipo",
|
||||
"@neighbors_unknownContact": {
|
||||
"placeholders": {
|
||||
"pubkey": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@neighbors_heardAgo": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"neighbors_unknownContact": "Clave pública desconocida {pubkey}",
|
||||
"neighbors_heardAgo": "Escuchado: {time} hace atrás",
|
||||
"settings_locationGPSEnable": "Habilitar GPS",
|
||||
"settings_locationGPSEnableSubtitle": "Habilita la actualización automática de la ubicación mediante GPS.",
|
||||
"settings_locationIntervalSec": "Intervalo para GPS (Segundos)",
|
||||
"settings_locationIntervalInvalid": "El intervalo debe ser de al menos 60 segundos y menor que 86400 segundos.",
|
||||
"contacts_manageRoom": "Gestionar Servidor de Habitación",
|
||||
"room_management": "Administración del Servidor de Habitación",
|
||||
"@community_joinConfirmation": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_created": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_joined": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_qrInstructions": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_alreadyMemberMessage": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleteConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleted": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_forCommunity": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_create": "Crear Comunidad",
|
||||
"community_createDesc": "Crear una nueva comunidad y compartir a través de código QR.",
|
||||
"community_title": "Comunidad",
|
||||
"community_join": "Únete",
|
||||
"community_joinTitle": "Únete a la comunidad",
|
||||
"community_joinConfirmation": "¿Quieres unirte a la comunidad \"{name}\"?",
|
||||
"community_scanQr": "Escanear Código QR de la Comunidad",
|
||||
"community_scanInstructions": "Apunte la cámara a un código QR de la comunidad",
|
||||
"community_showQr": "Mostrar Código QR",
|
||||
"community_publicChannel": "Comunidad Pública",
|
||||
"community_hashtagChannel": "Hashtag de la Comunidad",
|
||||
"community_name": "Nombre de la comunidad",
|
||||
"common_ok": "De acuerdo",
|
||||
"community_enterName": "Introducir nombre de comunidad",
|
||||
"community_created": "Comunidad \"{name}\" creada",
|
||||
"community_joined": "Se unió a la comunidad \"{name}\"",
|
||||
"community_qrTitle": "Compartir Comunidad",
|
||||
"community_qrInstructions": "Escanear este código QR para unirte a {name}",
|
||||
"community_hashtagPrivacyHint": "Los canales de hashtag de la comunidad solo son accesibles para los miembros de la comunidad",
|
||||
"community_invalidQrCode": "Código QR de comunidad no válido",
|
||||
"community_alreadyMember": "Ya eres Miembro",
|
||||
"community_alreadyMemberMessage": "Ya eres miembro de \"{name}\".",
|
||||
"community_addPublicChannel": "Añadir Canal Público de la Comunidad",
|
||||
"community_addPublicChannelHint": "Añade automáticamente el canal público para esta comunidad.",
|
||||
"community_noCommunities": "Aún no se han unido comunidades.",
|
||||
"community_scanOrCreate": "Escanear un código QR o crear una comunidad para comenzar",
|
||||
"community_manageCommunities": "Gestionar Comunidades",
|
||||
"community_delete": "Salir de la Comunidad",
|
||||
"community_deleteConfirm": "¿Salir de \"{name}\"?",
|
||||
"community_deleteChannelsWarning": "Esto también eliminará {count} canal(es) y sus mensajes.",
|
||||
"@community_deleteChannelsWarning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_deleted": "Has salido de la comunidad \"{name}\"",
|
||||
"community_addHashtagChannel": "Añadir Hashtag de la Comunidad",
|
||||
"community_addHashtagChannelDesc": "Añadir un canal con hashtag para esta comunidad",
|
||||
"community_selectCommunity": "Seleccionar Comunidad",
|
||||
"community_regularHashtag": "Etiqueta de Hashtag Regular",
|
||||
"community_regularHashtagDesc": "Hashtag público (cualquiera puede unirse)",
|
||||
"community_communityHashtag": "Hashtag de la Comunidad",
|
||||
"community_communityHashtagDesc": "Exclusivo para miembros de la comunidad",
|
||||
"community_forCommunity": "Para {name}",
|
||||
"@community_regenerateSecretConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretRegenerated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretUpdated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_scanToUpdateSecret": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_regenerateSecret": "Regenerar Contraseña Secreta",
|
||||
"community_regenerateSecretConfirm": "Regenerar la clave secreta para \"{name}\"? Todos los miembros deberán escanear el nuevo código QR para seguir comunicándose.",
|
||||
"community_secretRegenerated": "Código secreto regenerado para \"{name}\"",
|
||||
"community_regenerate": "Regenerar",
|
||||
"community_secretUpdated": "Confidencialidad actualizada para \"{name}\"",
|
||||
"community_scanToUpdateSecret": "Escanear el nuevo código QR para actualizar el secreto de \"{name}\"",
|
||||
"community_updateSecret": "Actualizar Contraseña",
|
||||
"@contacts_pathTraceTo": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pathTrace_you": "Tú",
|
||||
"pathTrace_failed": "El trazado de ruta falló.",
|
||||
"pathTrace_refreshTooltip": "Actualizar Path Trace",
|
||||
"contacts_pathTrace": "Rastreo de caminos",
|
||||
"contacts_repeaterPathTrace": "Rastrear ruta al repetidor",
|
||||
"contacts_repeaterPing": "Pingar repetidor",
|
||||
"contacts_ping": "Ping",
|
||||
"pathTrace_notAvailable": "El trazado de ruta no está disponible.",
|
||||
"contacts_roomPing": "Pingar servidor de sala",
|
||||
"contacts_roomPathTrace": "Rastreo de ruta al servidor de la habitación",
|
||||
"contacts_pathTraceTo": "Rastrear ruta a {name}",
|
||||
"contacts_chatTraceRoute": "Ruta de trazado",
|
||||
"appSettings_languageUk": "Ucraniano",
|
||||
"contacts_clipboardEmpty": "El portapapeles está vacío.",
|
||||
"appSettings_languageRu": "Ruso",
|
||||
"appSettings_enableMessageTracing": "Habilitar seguimiento de mensajes",
|
||||
"appSettings_enableMessageTracingSubtitle": "Mostrar metadatos detallados de enrutamiento y tiempo para los mensajes",
|
||||
"contacts_invalidAdvertFormat": "Datos de contacto no válidos",
|
||||
"contacts_floodAdvert": "Anuncio de inundación",
|
||||
"contacts_contactImported": "El contacto ha sido importado.",
|
||||
"contacts_contactImportFailed": "Contacto no se importó correctamente.",
|
||||
"contacts_zeroHopAdvert": "Anuncio de Zero Hop",
|
||||
"contacts_ShareContactZeroHop": "Compartir contacto por anuncio",
|
||||
"contacts_ShareContact": "Copiar contacto al Portapapeles",
|
||||
"contacts_copyAdvertToClipboard": "Copiar anuncio al portapapeles",
|
||||
"contacts_addContactFromClipboard": "Agregar contacto desde el portapapeles",
|
||||
"contacts_zeroHopContactAdvertFailed": "No se pudo enviar el contacto.",
|
||||
"contacts_zeroHopContactAdvertSent": "Envió contacto por anuncio.",
|
||||
"contacts_contactAdvertCopied": "Anuncio copiado al Portapapeles.",
|
||||
"contacts_contactAdvertCopyFailed": "Copiar anuncio al Portapapeles ha fallado.",
|
||||
"notification_activityTitle": "Actividad de MeshCore",
|
||||
"notification_messagesCount": "{count} {count, plural, =1{mensaje} other{mensajes}}",
|
||||
"@notification_messagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification_channelMessagesCount": "{count} {count, plural, =1{mensaje de canal} other{mensajes de canal}}",
|
||||
"@notification_channelMessagesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification_newNodesCount": "{count} {count, plural, =1{nuevo nodo} other{nuevos nodos}}",
|
||||
"@notification_newNodesCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification_newTypeDiscovered": "Nuevo {contactType} descubierto",
|
||||
"@notification_newTypeDiscovered": {
|
||||
"placeholders": {
|
||||
"contactType": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notification_receivedNewMessage": "Nuevo mensaje recibido",
|
||||
"settings_gpxExportContactsSubtitle": "Exporta compañeros con una ubicación a archivo GPX.",
|
||||
"settings_gpxExportRepeaters": "Exportar repetidores / servidor de sala a GPX",
|
||||
"settings_gpxExportSuccess": "Archivo GPX exportado con éxito.",
|
||||
"settings_gpxExportNoContacts": "No hay contactos para exportar.",
|
||||
"settings_gpxExportNotAvailable": "No compatible con tu dispositivo/SO",
|
||||
"settings_gpxExportError": "Hubo un error al exportar.",
|
||||
"settings_gpxExportRepeatersSubtitle": "Exporta repetidores o roomserver con una ubicación a un archivo GPX.",
|
||||
"settings_gpxExportAllSubtitle": "Exporta todos los contactos con una ubicación a un archivo GPX.",
|
||||
"settings_gpxExportAll": "Exportar todos los contactos a GPX",
|
||||
"settings_gpxExportContacts": "Exportar compañeros a GPX",
|
||||
"settings_gpxExportChat": "Ubicaciones de compañero",
|
||||
"settings_gpxExportRepeatersRoom": "Ubicaciones del servidor de repetidor y sala",
|
||||
"settings_gpxExportAllContacts": "Todas las ubicaciones de contactos",
|
||||
"settings_gpxExportShareText": "Datos del mapa exportados desde meshcore-open",
|
||||
"settings_gpxExportShareSubject": "meshcore-open exportación de datos de mapa GPX",
|
||||
"pathTrace_someHopsNoLocation": "Uno o más de los lúpulos carecen de una ubicación",
|
||||
"pathTrace_clearTooltip": "Borrar ruta",
|
||||
"map_runTrace": "Ejecutar Rastreo de Ruta",
|
||||
"map_tapToAdd": "Pulse en los nodos para agregarlos al camino.",
|
||||
"map_removeLast": "Eliminar último",
|
||||
"map_pathTraceCancelled": "Rastreo de ruta cancelado.",
|
||||
"scanner_bluetoothOffMessage": "Por favor, active el Bluetooth para escanear dispositivos.",
|
||||
"scanner_chromeRequired": "Navegador Chrome requerido",
|
||||
"scanner_chromeRequiredMessage": "Esta aplicación web requiere Google Chrome o un navegador basado en Chromium para el soporte de Bluetooth.",
|
||||
"scanner_bluetoothOff": "Bluetooth está desactivado.",
|
||||
"scanner_enableBluetooth": "Habilitar Bluetooth",
|
||||
"snrIndicator_nearByRepeaters": "Repetidores cercanos",
|
||||
"snrIndicator_lastSeen": "Visto por última vez",
|
||||
"chat_ShowAllPaths": "Mostrar todos los caminos",
|
||||
"settings_clientRepeatFreqWarning": "Para la comunicación fuera de la red, se requiere una frecuencia de 433, 869 o 918 MHz.",
|
||||
"settings_clientRepeat": "Repetir sin conexión",
|
||||
"settings_clientRepeatSubtitle": "Permita que este dispositivo repita los paquetes de red para otros usuarios.",
|
||||
"settings_aboutOpenMeteoAttribution": "Datos de elevación LOS: Open-Meteo (CC BY 4.0)",
|
||||
"appSettings_unitsTitle": "Unidades",
|
||||
"appSettings_unitsMetric": "Métrico (m/km)",
|
||||
"appSettings_unitsImperial": "Imperial (pies/millas)",
|
||||
"map_lineOfSight": "Línea de visión",
|
||||
"map_losScreenTitle": "Línea de visión",
|
||||
"losSelectStartEnd": "Seleccione los nodos de inicio y fin para LOS.",
|
||||
"losRunFailed": "Error en la comprobación de la línea de visión: {error}",
|
||||
"@losRunFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losClearAllPoints": "Borrar todos los puntos",
|
||||
"losRunToViewElevationProfile": "Ejecute LOS para ver el perfil de elevación",
|
||||
"losMenuTitle": "Menú LOS",
|
||||
"losMenuSubtitle": "Toque nodos o mantenga presionado el mapa para puntos personalizados",
|
||||
"losShowDisplayNodes": "Mostrar nodos de visualización",
|
||||
"losCustomPoints": "Puntos personalizados",
|
||||
"losCustomPointLabel": "Personalizado {index}",
|
||||
"@losCustomPointLabel": {
|
||||
"placeholders": {
|
||||
"index": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losPointA": "Punto A",
|
||||
"losPointB": "Punto B",
|
||||
"losAntennaA": "Antena A: {value} {unit}",
|
||||
"@losAntennaA": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losAntennaB": "Antena B: {value} {unit}",
|
||||
"@losAntennaB": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losRun": "Ejecutar LOS",
|
||||
"losNoElevationData": "Sin datos de elevación",
|
||||
"losProfileClear": "{distance} {distanceUnit}, despejar LOS, autorización mínima {clearance} {heightUnit}",
|
||||
"@losProfileClear": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"clearance": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losProfileBlocked": "{distance} {distanceUnit}, bloqueado por {obstruction} {heightUnit}",
|
||||
"@losProfileBlocked": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"obstruction": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losStatusChecking": "LOS: comprobando...",
|
||||
"losStatusNoData": "LOS: sin datos",
|
||||
"losStatusSummary": "LOS: {clear}/{total} claro, {blocked} bloqueado, {unknown} desconocido",
|
||||
"@losStatusSummary": {
|
||||
"placeholders": {
|
||||
"clear": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "int"
|
||||
},
|
||||
"unknown": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losErrorElevationUnavailable": "Datos de elevación no disponibles para una o más muestras.",
|
||||
"losErrorInvalidInput": "Datos de puntos/elevación no válidos para el cálculo de LOS.",
|
||||
"losRenameCustomPoint": "Cambiar el nombre del punto personalizado",
|
||||
"losPointName": "Nombre del punto",
|
||||
"losShowPanelTooltip": "Mostrar panel LOS",
|
||||
"losHidePanelTooltip": "Ocultar panel LOS",
|
||||
"losElevationAttribution": "Datos de elevación: Open-Meteo (CC BY 4.0)",
|
||||
"losLegendRadioHorizon": "Horizonte radioeléctrico",
|
||||
"losLegendLosBeam": "Línea de visión",
|
||||
"losLegendTerrain": "Terreno",
|
||||
"losFrequencyLabel": "Frecuencia",
|
||||
"losFrequencyInfoTooltip": "Ver detalles del cálculo",
|
||||
"losFrequencyDialogTitle": "Cálculo del horizonte radioeléctrico",
|
||||
"losFrequencyDialogDescription": "A partir de k={baselineK} en {baselineFreq} MHz, el cálculo ajusta el factor k para la banda actual de {frequencyMHz} MHz, que define el límite curvo del horizonte de radio.",
|
||||
"@losFrequencyDialogDescription": {
|
||||
"description": "Explain how the calculation uses the baseline frequency and derived k-factor.",
|
||||
"placeholders": {
|
||||
"baselineK": {
|
||||
"type": "double"
|
||||
},
|
||||
"baselineFreq": {
|
||||
"type": "double"
|
||||
},
|
||||
"frequencyMHz": {
|
||||
"type": "double"
|
||||
},
|
||||
"kFactor": {
|
||||
"type": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
"listFilter_favorites": "Favoritos",
|
||||
"listFilter_removeFromFavorites": "Eliminar de las favoritas",
|
||||
"listFilter_addToFavorites": "Añadir a favoritos",
|
||||
"@contacts_searchFavorites": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchUsers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRepeaters": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRoomServers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contacts_searchContactsNoNumber": "Buscar contactos...",
|
||||
"contacts_unread": "No leído",
|
||||
"contacts_searchFavorites": "Buscar {number}{str} Favoritos...",
|
||||
"contacts_searchUsers": "Buscar {number}{str} Usuarios...",
|
||||
"contacts_searchRepeaters": "Buscar {number}{str} Repetidores...",
|
||||
"contacts_searchRoomServers": "Buscar {number}{str} servidores de sala...",
|
||||
"contactsSettings_autoAddTitle": "Detección automática",
|
||||
"settings_contactSettings": "Configuración de contacto",
|
||||
"contactsSettings_autoAddUsersTitle": "Agregar usuarios automáticamente",
|
||||
"contactsSettings_otherTitle": "Otras configuraciones relacionadas con el contacto",
|
||||
"contactsSettings_autoAddUsersSubtitle": "Permitir que el compañero agregue automáticamente a los usuarios descubiertos.",
|
||||
"contactsSettings_autoAddRepeatersSubtitle": "Permitir que el compañero agregue automáticamente los repetidores descubiertos.",
|
||||
"contactsSettings_autoAddRoomServersSubtitle": "Permitir que el compañero agregue automáticamente los servidores de salas descubiertos.",
|
||||
"contactsSettings_autoAddSensorsTitle": "Agregar sensores automáticamente",
|
||||
"contactsSettings_title": "Configuración de contactos",
|
||||
"settings_contactSettingsSubtitle": "Configuración de cómo se agregan los contactos.",
|
||||
"contactsSettings_autoAddSensorsSubtitle": "Permitir que el compañero agregue automáticamente los sensores descubiertos.",
|
||||
"contactsSettings_autoAddRepeatersTitle": "Agregar repetidores automáticamente",
|
||||
"contactsSettings_overwriteOldestTitle": "Sobreescribir el más antiguo",
|
||||
"contactsSettings_autoAddRoomServersTitle": "Agregar automáticamente servidores de sala",
|
||||
"discoveredContacts_noMatching": "No se encontraron contactos coincidentes",
|
||||
"discoveredContacts_contactAdded": "Contacto agregado",
|
||||
"discoveredContacts_copyContact": "Copiar contacto al portapapeles",
|
||||
"discoveredContacts_deleteContact": "Eliminar contacto",
|
||||
"discoveredContacts_Title": "Contactos descubiertos",
|
||||
"discoveredContacts_searchHint": "Buscar contactos descubiertos",
|
||||
"discoveredContacts_addContact": "Agregar contacto",
|
||||
"contactsSettings_overwriteOldestSubtitle": "Cuando la lista de contactos esté llena, se reemplazará el contacto no favorito más antiguo.",
|
||||
"common_deleteAll": "Eliminar todo",
|
||||
"discoveredContacts_deleteContactAll": "Eliminar Todos los Contactos Descubiertos",
|
||||
"discoveredContacts_deleteContactAllContent": "¿Está seguro de que desea eliminar todos los contactos descubiertos!",
|
||||
"map_guessedLocation": "Ubicación estimada",
|
||||
"map_showGuessedLocations": "Mostrar las ubicaciones estimadas de los nodos.",
|
||||
"usbScreenTitle": "Conecte mediante USB",
|
||||
"connectionChoiceUsbLabel": "USB",
|
||||
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||
"usbScreenSubtitle": "Seleccione el dispositivo de serie detectado y conéctelo directamente a su nodo MeshCore.",
|
||||
"usbScreenStatus": "Seleccione un dispositivo USB",
|
||||
"usbScreenNote": "La comunicación serial a través de USB está activa en dispositivos Android compatibles y en plataformas de escritorio.",
|
||||
"usbScreenEmptyState": "No se encontraron dispositivos USB. Conecte uno y vuelva a intentar.",
|
||||
"usbErrorPermissionDenied": "Se denegó el permiso de acceso a través de USB.",
|
||||
"usbErrorDeviceMissing": "El dispositivo USB seleccionado ya no está disponible.",
|
||||
"usbErrorInvalidPort": "Seleccione un dispositivo USB válido.",
|
||||
"usbErrorBusy": "Ya se ha iniciado una solicitud de conexión USB adicional.",
|
||||
"usbErrorNotConnected": "No hay ningún dispositivo USB conectado.",
|
||||
"usbErrorOpenFailed": "No se pudo abrir el dispositivo USB seleccionado.",
|
||||
"usbErrorConnectFailed": "No se pudo conectar con el dispositivo USB seleccionado.",
|
||||
"usbErrorUnsupported": "La comunicación serial a través de USB no está soportada en esta plataforma.",
|
||||
"usbErrorAlreadyActive": "La conexión USB ya está activa.",
|
||||
"usbErrorNoDeviceSelected": "No se ha seleccionado ningún dispositivo USB.",
|
||||
"usbErrorPortClosed": "La conexión USB no está activa.",
|
||||
"usbFallbackDeviceName": "Dispositivo de serie web",
|
||||
"@usbConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"usbStatus_connecting": "Conectándose al dispositivo USB...",
|
||||
"usbStatus_searching": "Buscando dispositivos USB...",
|
||||
"usbStatus_notConnected": "Seleccione un dispositivo USB",
|
||||
"usbConnectionFailed": "Error al conectar mediante USB: {error}",
|
||||
"usbErrorConnectTimedOut": "La conexión ha caducado. Asegúrese de que el dispositivo tenga el firmware USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpScreenTitle": "Establecer conexión a través de TCP",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpHostLabel": "Dirección IP",
|
||||
"tcpPortLabel": "Puerto",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Ingrese la dirección final y conecte.",
|
||||
"tcpStatus_connectingTo": "Conectándose a {endpoint}...",
|
||||
"tcpErrorHostRequired": "Se requiere la dirección IP.",
|
||||
"tcpErrorPortInvalid": "El puerto debe estar entre 1 y 65535.",
|
||||
"tcpErrorUnsupported": "El protocolo de transporte TCP no está soportado en esta plataforma.",
|
||||
"tcpErrorTimedOut": "La conexión TCP ha caducado.",
|
||||
"tcpConnectionFailed": "Error en la conexión TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostrar Contactos de Descubrimiento",
|
||||
"map_setAsMyLocation": "Establecer mi ubicación",
|
||||
"settings_privacySubtitle": "Controlar qué información se comparte.",
|
||||
"settings_allowByContact": "Permitir por banderas de contacto",
|
||||
"settings_denyAll": "Denegar todo",
|
||||
"settings_telemetryBaseMode": "Modo base de telemetría",
|
||||
"settings_telemetryEnvironmentMode": "Modo de entorno de telemetría",
|
||||
"settings_advertLocationSubtitle": "Incluir ubicación en anuncio",
|
||||
"contact_info": "Información de contacto",
|
||||
"settings_privacySettingsDescription": "Elige qué información comparte tu dispositivo con otros.",
|
||||
"settings_allowAll": "Permitir todo",
|
||||
"settings_privacy": "Configuración de privacidad",
|
||||
"contact_settings": "Configuración de contacto",
|
||||
"settings_telemetryLocationMode": "Modo de ubicación de telemetría",
|
||||
"contact_teleBase": "Base de Telemetría",
|
||||
"contact_teleLoc": "Ubicación de telemetría",
|
||||
"settings_advertLocation": "Ubicación de anuncio",
|
||||
"contact_teleLocSubtitle": "Permitir el intercambio de datos de ubicación",
|
||||
"contact_clearChat": "Borrar chat",
|
||||
"contact_telemetry": "Telemetría",
|
||||
"contact_lastSeen": "Visto por última vez",
|
||||
"contact_teleBaseSubtitle": "Permitir el intercambio de nivel de batería y telemetría básica",
|
||||
"contact_teleEnv": "Entorno de Telemetría",
|
||||
"contact_teleEnvSubtitle": "Permitir el intercambio de datos de sensores de entorno",
|
||||
"@settings_multiAck": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings_telemetryModeUpdated": "Modo de telemetría actualizado",
|
||||
"settings_multiAck": "Multi-ACKs: {value}"
|
||||
}
|
||||
|
||||
+669
-84
File diff suppressed because it is too large
Load Diff
+591
-6
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"channels_channelDeleteFailed": "Impossibile eliminare il canale \"{name}\"",
|
||||
"@channels_channelDeleteFailed": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@@locale": "it",
|
||||
"appTitle": "MeshCore Open",
|
||||
"nav_contacts": "Contatti",
|
||||
@@ -131,9 +139,6 @@
|
||||
"settings_infoContactsCount": "Numero contatti",
|
||||
"settings_infoChannelCount": "Numero Canale",
|
||||
"settings_presets": "Preset",
|
||||
"settings_preset915Mhz": "915 MHz",
|
||||
"settings_preset868Mhz": "868 MHz",
|
||||
"settings_preset433Mhz": "433 MHz",
|
||||
"settings_frequency": "Frequenza (MHz)",
|
||||
"settings_frequencyHelper": "300,0 - 2500,0",
|
||||
"settings_frequencyInvalid": "Frequenza non valida (300-2500 MHz)",
|
||||
@@ -143,8 +148,6 @@
|
||||
"settings_txPower": "TX Potenza (dBm)",
|
||||
"settings_txPowerHelper": "0 - 22",
|
||||
"settings_txPowerInvalid": "Potere TX non valido (0-22 dBm)",
|
||||
"settings_longRange": "Lungo Raggio",
|
||||
"settings_fastSpeed": "Velocità Rapida",
|
||||
"settings_error": "Errore: {message}",
|
||||
"@settings_error": {
|
||||
"placeholders": {
|
||||
@@ -282,6 +285,7 @@
|
||||
"contacts_newGroup": "Nuovo Gruppo",
|
||||
"contacts_groupName": "Nome gruppo",
|
||||
"contacts_groupNameRequired": "Il nome del gruppo è obbligatorio.",
|
||||
"contacts_groupNameReserved": "Questo nome del gruppo è riservato",
|
||||
"contacts_groupAlreadyExists": "Il gruppo \"{name}\" esiste già.",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
@@ -339,6 +343,8 @@
|
||||
"channels_publicChannel": "Canale pubblico",
|
||||
"channels_privateChannel": "Canale privato",
|
||||
"channels_editChannel": "Modifica canale",
|
||||
"channels_muteChannel": "Silenzia canale",
|
||||
"channels_unmuteChannel": "Attiva notifiche canale",
|
||||
"channels_deleteChannel": "Elimina canale",
|
||||
"channels_deleteChannelConfirm": "Eliminare \"{name}\"? Non può essere annullato.",
|
||||
"@channels_deleteChannelConfirm": {
|
||||
@@ -604,6 +610,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_openLink": "Aprire il link?",
|
||||
"chat_openLinkConfirmation": "Vuoi aprire questo link nel tuo browser?",
|
||||
"chat_open": "Apri",
|
||||
"chat_couldNotOpenLink": "Impossibile aprire il link: {url}",
|
||||
"@chat_couldNotOpenLink": {
|
||||
"placeholders": {
|
||||
"url": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_invalidLink": "Formato di link non valido",
|
||||
"map_title": "Mappa Nodi",
|
||||
"map_noNodesWithLocation": "Nessun nodo con dati di posizione",
|
||||
"map_nodesNeedGps": "I nodi devono condividere le loro coordinate GPS\nper apparire sulla mappa",
|
||||
@@ -821,6 +839,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"login_failedMessage": "Accesso fallito. La password non è corretta oppure il ripetitore non è raggiungibile.",
|
||||
"common_reload": "Ricaricare",
|
||||
"common_clear": "Cancella",
|
||||
"path_currentPath": "Percorso corrente: {path}",
|
||||
@@ -1335,5 +1354,571 @@
|
||||
"listFilter_repeaters": "Ripetitori",
|
||||
"listFilter_roomServers": "Server della stanza",
|
||||
"listFilter_unreadOnly": "Solo non letto",
|
||||
"listFilter_newGroup": "Nuovo gruppo"
|
||||
"listFilter_newGroup": "Nuovo gruppo",
|
||||
"@neighbors_errorLoading": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_neighbors": "Vicini",
|
||||
"repeater_neighborsSubtitle": "Visualizza vicini di salto pari a zero.",
|
||||
"neighbors_receivedData": "Ricevute dati vicini",
|
||||
"neighbors_requestTimedOut": "I vicini richiedono un timeout.",
|
||||
"neighbors_errorLoading": "Errore nel caricamento dei vicini: {error}",
|
||||
"neighbors_repeatersNeighbors": "Ripetitori Vicini",
|
||||
"neighbors_noData": "Nessun dato sugli vicini disponibile.",
|
||||
"channels_createPrivateChannel": "Crea un Canale Privato",
|
||||
"channels_createPrivateChannelDesc": "Protetta con una chiave segreta.",
|
||||
"channels_joinPrivateChannel": "Unisciti a un Canale Privato",
|
||||
"channels_joinPrivateChannelDesc": "Inserire manualmente una chiave segreta.",
|
||||
"channels_joinPublicChannel": "Unisciti al Canale Pubblico",
|
||||
"channels_joinPublicChannelDesc": "Chiunque può unirsi a questo canale.",
|
||||
"channels_joinHashtagChannel": "Unisciti a un Canale con Hashtag",
|
||||
"channels_joinHashtagChannelDesc": "Chiunque può unirsi ai canali hashtag.",
|
||||
"channels_scanQrCode": "Scansiona un codice QR",
|
||||
"channels_scanQrCodeComingSoon": "Arriverà presto",
|
||||
"channels_enterHashtag": "Inserisci hashtag",
|
||||
"channels_hashtagHint": "es. #team",
|
||||
"@neighbors_unknownContact": {
|
||||
"placeholders": {
|
||||
"pubkey": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@neighbors_heardAgo": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"neighbors_heardAgo": "Sentito: {time} fa",
|
||||
"neighbors_unknownContact": "Chiave pubblica sconosciuta {pubkey}",
|
||||
"settings_locationGPSEnable": "Abilita GPS",
|
||||
"settings_locationGPSEnableSubtitle": "Abilita l'aggiornamento automatico della posizione tramite GPS.",
|
||||
"settings_locationIntervalSec": "Intervallo GPS (Secondi)",
|
||||
"settings_locationIntervalInvalid": "L'intervallo deve essere di almeno 60 secondi e inferiore a 86400 secondi.",
|
||||
"contacts_manageRoom": "Gestisci Server Camera",
|
||||
"room_management": "Gestione del Server di Camera",
|
||||
"@community_joinConfirmation": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_created": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_joined": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_qrInstructions": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_alreadyMemberMessage": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleteConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleted": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_forCommunity": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common_ok": "OK",
|
||||
"community_title": "Comunità",
|
||||
"community_create": "Crea Comunità",
|
||||
"community_createDesc": "Crea una nuova comunità e condividila tramite codice QR.",
|
||||
"community_join": "Unisciti",
|
||||
"community_joinTitle": "Unisciti alla Community",
|
||||
"community_joinConfirmation": "Vuoi unirti alla community \"{name}\"?",
|
||||
"community_scanQr": "Scansiona il QR Code della Community",
|
||||
"community_scanInstructions": "Punta la fotocamera su un codice QR della comunità",
|
||||
"community_showQr": "Mostra il codice QR",
|
||||
"community_publicChannel": "Comunità Pubblica",
|
||||
"community_hashtagChannel": "Hashtag della Comunità",
|
||||
"community_name": "Nome della Comunità",
|
||||
"community_enterName": "Inserisci il nome della comunità",
|
||||
"community_created": "Comunità \"{name}\" creata",
|
||||
"community_joined": "Unito alla comunità \"{name}\"",
|
||||
"community_qrTitle": "Condividi Comunità",
|
||||
"community_qrInstructions": "Scansiona questo codice QR per unirti a {name}",
|
||||
"community_hashtagPrivacyHint": "I canali hashtag della community sono accessibili solo ai membri della community",
|
||||
"community_invalidQrCode": "Codice QR della community non valido",
|
||||
"community_alreadyMember": "Già membro",
|
||||
"community_alreadyMemberMessage": "Sei già un membro di \"{name}\".",
|
||||
"community_addPublicChannel": "Aggiungi Canale Pubblico della Comunità",
|
||||
"community_addPublicChannelHint": "Aggiungi automaticamente il canale pubblico per questa community",
|
||||
"community_noCommunities": "Nessun gruppo aggiunto finora",
|
||||
"community_scanOrCreate": "Scansiona un codice QR o crea una community per iniziare.",
|
||||
"community_manageCommunities": "Gestisci Comunità",
|
||||
"community_delete": "Lascia la Comunità",
|
||||
"community_deleteConfirm": "Uscire da \"{name}\"?",
|
||||
"community_deleteChannelsWarning": "Questo eliminerà anche {count} canale/i e i loro messaggi.",
|
||||
"@community_deleteChannelsWarning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_deleted": "Hai lasciato la comunità \"{name}\"",
|
||||
"community_addHashtagChannel": "Aggiungi Hashtag della Community",
|
||||
"community_addHashtagChannelDesc": "Aggiungi un canale con hashtag per questa community",
|
||||
"community_selectCommunity": "Seleziona Comunità",
|
||||
"community_regularHashtag": "Hashtag regolare",
|
||||
"community_regularHashtagDesc": "Hashtag pubblico (chiunque può unirsi)",
|
||||
"community_communityHashtag": "Hashtag della Comunità",
|
||||
"community_communityHashtagDesc": "Visibile solo ai membri della comunità",
|
||||
"community_forCommunity": "Per {name}",
|
||||
"@community_regenerateSecretConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretRegenerated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretUpdated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_scanToUpdateSecret": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_regenerateSecretConfirm": "Regenera la chiave segreta per \"{name}\"? Tutti i membri dovranno scansionare il nuovo codice QR per continuare a comunicare.",
|
||||
"community_regenerateSecret": "Ri genera la chiave segreta",
|
||||
"community_regenerate": "Rigenera",
|
||||
"community_secretRegenerated": "Codice segreto rigenerato per \"{name}\"",
|
||||
"community_updateSecret": "Aggiorna Segreto",
|
||||
"community_secretUpdated": "Segreto aggiornato per \"{name}\"",
|
||||
"community_scanToUpdateSecret": "Scansiona il nuovo codice QR per aggiornare il segreto di \"{name}\"",
|
||||
"@contacts_pathTraceTo": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pathTrace_failed": "Tracciamento del percorso fallito.",
|
||||
"pathTrace_you": "Tu",
|
||||
"pathTrace_notAvailable": "Tracciamento del percorso non disponibile.",
|
||||
"pathTrace_refreshTooltip": "Aggiorna Path Trace.",
|
||||
"contacts_ping": "Ping",
|
||||
"contacts_repeaterPathTrace": "Traccia percorso al ripetitore",
|
||||
"contacts_roomPathTrace": "Traccia del percorso al server della stanza",
|
||||
"contacts_pathTrace": "Traccia Percorso",
|
||||
"contacts_repeaterPing": "Ripetitore ping",
|
||||
"contacts_pathTraceTo": "Traccia percorso verso {name}",
|
||||
"contacts_roomPing": "Ping al server della stanza",
|
||||
"contacts_chatTraceRoute": "Traccia percorso path",
|
||||
"appSettings_languageRu": "Russo",
|
||||
"contacts_invalidAdvertFormat": "Dati di contatto non validi",
|
||||
"appSettings_languageUk": "Ucraino",
|
||||
"appSettings_enableMessageTracing": "Abilita tracciamento messaggi",
|
||||
"appSettings_enableMessageTracingSubtitle": "Mostra metadati dettagliati su instradamento e tempi per i messaggi",
|
||||
"contacts_zeroHopAdvert": "Annuncio Zero Hop",
|
||||
"contacts_floodAdvert": "Annuncio alluvionale",
|
||||
"contacts_copyAdvertToClipboard": "Copia Annuncio negli Appunti",
|
||||
"contacts_addContactFromClipboard": "Aggiungere contatto dalla clipboard",
|
||||
"contacts_clipboardEmpty": "La clipboard è vuota.",
|
||||
"contacts_ShareContact": "Copia contatto negli Appunti",
|
||||
"contacts_contactImported": "Il contatto è stato importato.",
|
||||
"contacts_contactImportFailed": "Contatto non importato con successo.",
|
||||
"contacts_zeroHopContactAdvertSent": "Inviato contatto tramite annuncio.",
|
||||
"contacts_contactAdvertCopyFailed": "Copia dell'annuncio nella Clipboard non riuscita.",
|
||||
"contacts_ShareContactZeroHop": "Condividi contatto tramite annuncio",
|
||||
"contacts_zeroHopContactAdvertFailed": "Invio del contatto non riuscito.",
|
||||
"contacts_contactAdvertCopied": "Annuncio copiato negli Appunti.",
|
||||
"notification_activityTitle": "Attività MeshCore",
|
||||
"notification_messagesCount": "{count} {count, plural, =1{messaggio} other{messaggi}}",
|
||||
"notification_channelMessagesCount": "{count} {count, plural, =1{messaggio del canale} other{messaggi del canale}}",
|
||||
"notification_newNodesCount": "{count} {count, plural, =1{nuovo nodo} other{nuovi nodi}}",
|
||||
"notification_newTypeDiscovered": "Nuovo {contactType} scoperto",
|
||||
"notification_receivedNewMessage": "Nuovo messaggio ricevuto",
|
||||
"settings_gpxExportRepeaters": "Esporta ripetitori / server di stanza in GPX",
|
||||
"settings_gpxExportContacts": "Esporta compagni in GPX",
|
||||
"settings_gpxExportSuccess": "Esportazione del file GPX completata con successo.",
|
||||
"settings_gpxExportNoContacts": "Nessun contatto da esportare.",
|
||||
"settings_gpxExportNotAvailable": "Non supportato sul tuo dispositivo/Sistema Operativo",
|
||||
"settings_gpxExportError": "Si è verificato un errore durante l'esportazione.",
|
||||
"settings_gpxExportRepeatersSubtitle": "Esporta ripetitori / roomserver con una posizione in un file GPX.",
|
||||
"settings_gpxExportContactsSubtitle": "Esporta i compagni con una posizione in un file GPX.",
|
||||
"settings_gpxExportAll": "Esporta tutti i contatti in GPX",
|
||||
"settings_gpxExportAllSubtitle": "Esporta tutti i contatti con una posizione in un file GPX.",
|
||||
"settings_gpxExportChat": "Posizioni dei compagni",
|
||||
"settings_gpxExportRepeatersRoom": "Posizioni del server ripetitore e della stanza",
|
||||
"settings_gpxExportAllContacts": "Tutte le posizioni dei contatti",
|
||||
"settings_gpxExportShareText": "Dati mappa esportati da meshcore-open",
|
||||
"settings_gpxExportShareSubject": "meshcore-open esportazione dati mappa GPX",
|
||||
"pathTrace_someHopsNoLocation": "Uno o più dei luppoli mancano di una posizione!",
|
||||
"map_removeLast": "Rimuovi ultimo",
|
||||
"map_pathTraceCancelled": "Tracciamento del percorso annullato.",
|
||||
"pathTrace_clearTooltip": "Pulisci percorso",
|
||||
"map_runTrace": "Esegui Path Trace",
|
||||
"map_tapToAdd": "Tocca i nodi per aggiungerli al percorso.",
|
||||
"scanner_bluetoothOff": "Il Bluetooth è disattivato.",
|
||||
"scanner_bluetoothOffMessage": "Si prega di attivare il Bluetooth per effettuare la scansione dei dispositivi.",
|
||||
"scanner_chromeRequired": "Browser Chrome richiesto",
|
||||
"scanner_chromeRequiredMessage": "Questa applicazione web richiede Google Chrome o un browser basato su Chromium per il supporto Bluetooth.",
|
||||
"scanner_enableBluetooth": "Abilita il Bluetooth",
|
||||
"snrIndicator_nearByRepeaters": "Ripetitori vicini",
|
||||
"snrIndicator_lastSeen": "Ultimo accesso",
|
||||
"chat_ShowAllPaths": "Mostra tutti i percorsi",
|
||||
"settings_clientRepeat": "Ripetizione \"fuori dalla rete\"",
|
||||
"settings_clientRepeatFreqWarning": "Per la comunicazione fuori rete, è necessario utilizzare frequenze di 433, 869 o 918 MHz.",
|
||||
"settings_clientRepeatSubtitle": "Permetti a questo dispositivo di ripetere i pacchetti di rete per gli altri.",
|
||||
"settings_aboutOpenMeteoAttribution": "Dati di elevazione LOS: Open-Meteo (CC BY 4.0)",
|
||||
"appSettings_unitsTitle": "Unità",
|
||||
"appSettings_unitsMetric": "Metrico (m/km)",
|
||||
"appSettings_unitsImperial": "Imperiale (ft / mi)",
|
||||
"map_lineOfSight": "Linea di vista",
|
||||
"map_losScreenTitle": "Linea di vista",
|
||||
"losSelectStartEnd": "Seleziona i nodi iniziali e finali per la LOS.",
|
||||
"losRunFailed": "Controllo della linea di vista fallito: {error}",
|
||||
"@losRunFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losClearAllPoints": "Cancella tutti i punti",
|
||||
"losRunToViewElevationProfile": "Eseguire LOS per visualizzare il profilo altimetrico",
|
||||
"losMenuTitle": "Menù LOS",
|
||||
"losMenuSubtitle": "Tocca i nodi o premi a lungo la mappa per punti personalizzati",
|
||||
"losShowDisplayNodes": "Mostra i nodi di visualizzazione",
|
||||
"losCustomPoints": "Punti personalizzati",
|
||||
"losCustomPointLabel": "Personalizzato {index}",
|
||||
"@losCustomPointLabel": {
|
||||
"placeholders": {
|
||||
"index": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losPointA": "Punto A",
|
||||
"losPointB": "Punto B",
|
||||
"losAntennaA": "Antenna A: {value} {unit}",
|
||||
"@losAntennaA": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losAntennaB": "Antenna B: {value} {unit}",
|
||||
"@losAntennaB": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losRun": "Esegui LOS",
|
||||
"losNoElevationData": "Nessun dato di elevazione",
|
||||
"losProfileClear": "{distance} {distanceUnit}, libera LOS, distanza minima {clearance} {heightUnit}",
|
||||
"@losProfileClear": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"clearance": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losProfileBlocked": "{distance} {distanceUnit}, bloccato da {obstruction} {heightUnit}",
|
||||
"@losProfileBlocked": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"obstruction": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losStatusChecking": "LOS: controllo...",
|
||||
"losStatusNoData": "LOS: nessun dato",
|
||||
"losStatusSummary": "LOS: {clear}/{total} libera, {blocked} bloccato, {unknown} sconosciuto",
|
||||
"@losStatusSummary": {
|
||||
"placeholders": {
|
||||
"clear": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "int"
|
||||
},
|
||||
"unknown": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losErrorElevationUnavailable": "Dati di elevazione non disponibili per uno o più campioni.",
|
||||
"losErrorInvalidInput": "Dati punti/elevazione non validi per il calcolo della LOS.",
|
||||
"losRenameCustomPoint": "Rinomina punto personalizzato",
|
||||
"losPointName": "Nome del punto",
|
||||
"losShowPanelTooltip": "Mostra il pannello LOS",
|
||||
"losHidePanelTooltip": "Nascondi il pannello LOS",
|
||||
"losElevationAttribution": "Dati di elevazione: Open-Meteo (CC BY 4.0)",
|
||||
"losLegendRadioHorizon": "Orizzonte radio",
|
||||
"losLegendLosBeam": "Linea di vista",
|
||||
"losLegendTerrain": "Terreno",
|
||||
"losFrequencyLabel": "Frequenza",
|
||||
"losFrequencyInfoTooltip": "Visualizza i dettagli del calcolo",
|
||||
"losFrequencyDialogTitle": "Calcolo dell’orizzonte radio",
|
||||
"losFrequencyDialogDescription": "Partendo da k={baselineK} a {baselineFreq} MHz, il calcolo regola il fattore k per l'attuale banda {frequencyMHz} MHz, che definisce il limite curvo dell'orizzonte radio.",
|
||||
"@losFrequencyDialogDescription": {
|
||||
"description": "Explain how the calculation uses the baseline frequency and derived k-factor.",
|
||||
"placeholders": {
|
||||
"baselineK": {
|
||||
"type": "double"
|
||||
},
|
||||
"baselineFreq": {
|
||||
"type": "double"
|
||||
},
|
||||
"frequencyMHz": {
|
||||
"type": "double"
|
||||
},
|
||||
"kFactor": {
|
||||
"type": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
"listFilter_addToFavorites": "Aggiungi ai preferiti",
|
||||
"listFilter_removeFromFavorites": "Rimuovi dai preferiti",
|
||||
"listFilter_favorites": "Preferiti",
|
||||
"@contacts_searchFavorites": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchUsers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRepeaters": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRoomServers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contacts_searchUsers": "Cerca {number}{str} Utenti...",
|
||||
"contacts_searchContactsNoNumber": "Cerca Contatti...",
|
||||
"contacts_searchFavorites": "Cerca {number}{str} Preferiti...",
|
||||
"contacts_unread": "Non letti",
|
||||
"contacts_searchRepeaters": "Cerca {number}{str} Ripetitori...",
|
||||
"contacts_searchRoomServers": "Cerca {number}{str} server Room...",
|
||||
"contactsSettings_title": "Impostazioni dei contatti",
|
||||
"settings_contactSettings": "Impostazioni di contatto",
|
||||
"contactsSettings_otherTitle": "Altre impostazioni relative ai contatti",
|
||||
"contactsSettings_autoAddUsersSubtitle": "Consenti al compagno di aggiungere automaticamente gli utenti scoperti.",
|
||||
"contactsSettings_autoAddRepeatersTitle": "Aggiungere ripetitori automaticamente",
|
||||
"contactsSettings_autoAddRoomServersSubtitle": "Consenti al compagno di aggiungere automaticamente i server delle stanze scoperte.",
|
||||
"contactsSettings_autoAddSensorsTitle": "Aggiungere automaticamente i sensori",
|
||||
"settings_contactSettingsSubtitle": "Impostazioni per l'aggiunta dei contatti",
|
||||
"contactsSettings_autoAddUsersTitle": "Aggiungere utenti automaticamente",
|
||||
"contactsSettings_autoAddTitle": "Scoperta automatica",
|
||||
"contactsSettings_autoAddSensorsSubtitle": "Consenti al compagno di aggiungere automaticamente i sensori scoperti",
|
||||
"discoveredContacts_noMatching": "Nessun contatto corrispondente",
|
||||
"contactsSettings_autoAddRepeatersSubtitle": "Consenti al compagno di aggiungere automaticamente i ripetitori scoperti.",
|
||||
"discoveredContacts_searchHint": "Cerca contatti scoperti",
|
||||
"contactsSettings_autoAddRoomServersTitle": "Aggiungere automaticamente i server delle stanze",
|
||||
"discoveredContacts_addContact": "Aggiungi contatto",
|
||||
"contactsSettings_overwriteOldestTitle": "Sostituisci il più vecchio",
|
||||
"discoveredContacts_Title": "Contatti scoperti",
|
||||
"discoveredContacts_contactAdded": "Contatto aggiunto",
|
||||
"discoveredContacts_deleteContact": "Elimina Contatto",
|
||||
"discoveredContacts_copyContact": "Copia contatto negli appunti",
|
||||
"contactsSettings_overwriteOldestSubtitle": "Quando l'elenco dei contatti è pieno, il contatto più vecchio non tra i preferiti verrà sostituito.",
|
||||
"common_deleteAll": "Elimina tutto",
|
||||
"discoveredContacts_deleteContactAllContent": "Sei sicuro di voler eliminare tutti i contatti scoperti?",
|
||||
"discoveredContacts_deleteContactAll": "Eliminare tutti i contatti scoperti",
|
||||
"map_guessedLocation": "Località indovinata",
|
||||
"map_showGuessedLocations": "Mostra le posizioni stimate dei nodi",
|
||||
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||
"usbScreenSubtitle": "Seleziona il dispositivo seriale rilevato e connettilo direttamente al tuo nodo MeshCore.",
|
||||
"connectionChoiceUsbLabel": "USB",
|
||||
"usbScreenTitle": "Connessione tramite USB",
|
||||
"usbScreenStatus": "Seleziona un dispositivo USB",
|
||||
"usbScreenNote": "La comunicazione seriale USB è attiva sui dispositivi Android supportati e sulle piattaforme desktop.",
|
||||
"usbScreenEmptyState": "Nessun dispositivo USB rilevato. Collegare uno e aggiornare.",
|
||||
"usbErrorPermissionDenied": "È stato negato l'accesso tramite USB.",
|
||||
"usbErrorDeviceMissing": "Il dispositivo USB selezionato non è più disponibile.",
|
||||
"usbErrorInvalidPort": "Seleziona un dispositivo USB valido.",
|
||||
"usbErrorBusy": "Un'altra richiesta di connessione tramite USB è già in corso.",
|
||||
"usbErrorNotConnected": "Non è collegato alcun dispositivo USB.",
|
||||
"usbErrorOpenFailed": "Impossibile aprire il dispositivo USB selezionato.",
|
||||
"usbErrorConnectFailed": "Impossibile connettersi al dispositivo USB selezionato.",
|
||||
"usbErrorUnsupported": "La comunicazione seriale tramite USB non è supportata su questa piattaforma.",
|
||||
"usbErrorAlreadyActive": "La connessione USB è già attiva.",
|
||||
"usbErrorNoDeviceSelected": "Non è stato selezionato alcun dispositivo USB.",
|
||||
"usbErrorPortClosed": "La connessione USB non è attiva.",
|
||||
"usbFallbackDeviceName": "Dispositivo per comunicazione seriale su rete",
|
||||
"@usbConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"usbStatus_searching": "Ricerca di dispositivi USB...",
|
||||
"usbConnectionFailed": "Errore nella connessione USB: {error}",
|
||||
"usbStatus_notConnected": "Seleziona un dispositivo USB",
|
||||
"usbStatus_connecting": "Connessione al dispositivo USB...",
|
||||
"usbErrorConnectTimedOut": "La connessione è scaduta. Assicurarsi che il dispositivo abbia il firmware USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostLabel": "Indirizzo IP",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpScreenTitle": "Stabilire una connessione tramite TCP",
|
||||
"tcpPortLabel": "Porta",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Inserisci l'endpoint e connettiti.",
|
||||
"tcpStatus_connectingTo": "Connessione a {endpoint}...",
|
||||
"tcpErrorHostRequired": "È necessario fornire un indirizzo IP.",
|
||||
"tcpErrorPortInvalid": "La dimensione della porta deve essere compresa tra 1 e 65535.",
|
||||
"tcpErrorUnsupported": "Il protocollo TCP non è supportato su questa piattaforma.",
|
||||
"tcpErrorTimedOut": "La connessione TCP è scaduta.",
|
||||
"tcpConnectionFailed": "Impossibile stabilire la connessione TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostra Contatti di Discovery",
|
||||
"map_setAsMyLocation": "Imposta come la mia posizione",
|
||||
"settings_privacySettingsDescription": "Scegli le informazioni che il tuo dispositivo condivide con gli altri.",
|
||||
"settings_allowByContact": "Consenti in base ai flag di contatto",
|
||||
"settings_telemetryLocationMode": "Modalità di posizionamento telemetrico",
|
||||
"settings_telemetryEnvironmentMode": "Modalità di ambiente di telemetria",
|
||||
"settings_advertLocation": "Posizione dell'annuncio",
|
||||
"settings_advertLocationSubtitle": "Includi la posizione nell'annuncio",
|
||||
"settings_privacy": "Impostazioni sulla privacy",
|
||||
"settings_denyAll": "Negare tutto",
|
||||
"settings_privacySubtitle": "Controlla le informazioni che vengono condivise.",
|
||||
"settings_allowAll": "Consenti tutto",
|
||||
"contact_info": "Informazioni di Contatto",
|
||||
"settings_telemetryBaseMode": "Modalità di base di telemetria",
|
||||
"contact_teleBase": "Base di telemetria",
|
||||
"contact_teleLoc": "Posizione telemetria",
|
||||
"contact_teleLocSubtitle": "Consenti la condivisione dei dati di posizione",
|
||||
"contact_clearChat": "Cancella chat",
|
||||
"contact_telemetry": "Telemetria",
|
||||
"contact_settings": "Impostazioni di contatto",
|
||||
"contact_lastSeen": "Ultimo accesso",
|
||||
"contact_teleBaseSubtitle": "Consenti la condivisione del livello della batteria e della telemetria di base",
|
||||
"contact_teleEnvSubtitle": "Consenti la condivisione dei dati del sensore ambientale",
|
||||
"contact_teleEnv": "Ambiente di telemetria",
|
||||
"@settings_multiAck": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings_telemetryModeUpdated": "Modalità telemetria aggiornata",
|
||||
"settings_multiAck": "Multi-ACKs: {value}"
|
||||
}
|
||||
|
||||
+1770
-35
File diff suppressed because it is too large
Load Diff
+1055
-12
File diff suppressed because it is too large
Load Diff
+1221
-169
File diff suppressed because it is too large
Load Diff
+1040
-21
File diff suppressed because it is too large
Load Diff
+1069
-17
File diff suppressed because it is too large
Load Diff
+1155
-93
File diff suppressed because it is too large
Load Diff
+1066
-12
File diff suppressed because it is too large
Load Diff
+1143
-102
File diff suppressed because it is too large
Load Diff
+1069
-12
File diff suppressed because it is too large
Load Diff
+1061
-12
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1054
-13
File diff suppressed because it is too large
Load Diff
+1185
-147
File diff suppressed because it is too large
Load Diff
+1045
-12
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1338
-419
File diff suppressed because it is too large
Load Diff
+678
-93
File diff suppressed because it is too large
Load Diff
+591
-6
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"channels_channelDeleteFailed": "Nie udało się usunąć kanału \"{name}\"",
|
||||
"@channels_channelDeleteFailed": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@@locale": "pl",
|
||||
"appTitle": "MeshCore Open",
|
||||
"nav_contacts": "Kontakty",
|
||||
@@ -131,9 +139,6 @@
|
||||
"settings_infoContactsCount": "Liczba kontaktów",
|
||||
"settings_infoChannelCount": "Liczba kanałów",
|
||||
"settings_presets": "Preset",
|
||||
"settings_preset915Mhz": "915 MHz",
|
||||
"settings_preset868Mhz": "868 MHz",
|
||||
"settings_preset433Mhz": "433 MHz",
|
||||
"settings_frequency": "Częstotliwość (MHz)",
|
||||
"settings_frequencyHelper": "300,0 - 2500,0",
|
||||
"settings_frequencyInvalid": "Nieprawidłowa częstotliwość (300-2500 MHz)",
|
||||
@@ -143,8 +148,6 @@
|
||||
"settings_txPower": "TX Moc (dBm)",
|
||||
"settings_txPowerHelper": "0 - 22",
|
||||
"settings_txPowerInvalid": "Nieprawidłowa moc TX (0-22 dBm)",
|
||||
"settings_longRange": "Długi zasięg",
|
||||
"settings_fastSpeed": "Szybka prędkość",
|
||||
"settings_error": "Błąd: {message}",
|
||||
"@settings_error": {
|
||||
"placeholders": {
|
||||
@@ -282,6 +285,7 @@
|
||||
"contacts_newGroup": "Nowa Grupa",
|
||||
"contacts_groupName": "Nazwa grupy",
|
||||
"contacts_groupNameRequired": "Nazwa grupy jest wymagana",
|
||||
"contacts_groupNameReserved": "Ta nazwa grupy jest zastrzeżona",
|
||||
"contacts_groupAlreadyExists": "Grupa \"{name}\" już istnieje",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
@@ -339,6 +343,8 @@
|
||||
"channels_publicChannel": "Kanał publiczny",
|
||||
"channels_privateChannel": "Prywatny kanał",
|
||||
"channels_editChannel": "Edytuj kanał",
|
||||
"channels_muteChannel": "Wycisz kanał",
|
||||
"channels_unmuteChannel": "Wyłącz wyciszenie kanału",
|
||||
"channels_deleteChannel": "Usuń kanał",
|
||||
"channels_deleteChannelConfirm": "Usuń \"{name}\"? Nie można tego cofnąć.",
|
||||
"@channels_deleteChannelConfirm": {
|
||||
@@ -604,6 +610,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_openLink": "Otworzyć link?",
|
||||
"chat_openLinkConfirmation": "Czy chcesz otworzyć ten link w przeglądarce?",
|
||||
"chat_open": "Otwórz",
|
||||
"chat_couldNotOpenLink": "Nie można otworzyć linku: {url}",
|
||||
"@chat_couldNotOpenLink": {
|
||||
"placeholders": {
|
||||
"url": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_invalidLink": "Nieprawidłowy format linku",
|
||||
"map_title": "Mapa węzłów",
|
||||
"map_noNodesWithLocation": "Brak węzłów z danymi lokalizacyjnymi",
|
||||
"map_nodesNeedGps": "Węzły muszą udostępniać swoje współrzędne GPS,\naby pojawić się na mapie.",
|
||||
@@ -821,6 +839,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"login_failedMessage": "Logowanie nie powiodło się. Hasło jest nieprawidłowe albo repeater jest nieosiągalny.",
|
||||
"common_reload": "Ponownie załadować",
|
||||
"common_clear": "Wyczyść",
|
||||
"path_currentPath": "Aktualny ścieżka: {path}",
|
||||
@@ -1335,5 +1354,571 @@
|
||||
"listFilter_repeaters": "Powtarzacze",
|
||||
"listFilter_roomServers": "Serwery pokoju",
|
||||
"listFilter_unreadOnly": "Tylko nieprzeczytane",
|
||||
"listFilter_newGroup": "Nowa grupa"
|
||||
"listFilter_newGroup": "Nowa grupa",
|
||||
"@neighbors_errorLoading": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_neighbors": "Sąsiedzi",
|
||||
"repeater_neighborsSubtitle": "Wyświetl sąsiedztwo zerowych hopów.",
|
||||
"neighbors_receivedData": "Otrzymano dane sąsiedztwa",
|
||||
"neighbors_requestTimedOut": "Sąsiedzi proszą o wyłączenie timingu.",
|
||||
"neighbors_errorLoading": "Błąd podczas ładowania sąsiadów: {error}",
|
||||
"neighbors_repeatersNeighbors": "Powtarzacze Sąsiedzi",
|
||||
"neighbors_noData": "Brak danych dotyczących sąsiadów.",
|
||||
"channels_joinPrivateChannelDesc": "Ręcznie wprowadź klucz tajny.",
|
||||
"channels_createPrivateChannel": "Utwórz Prywatny Kanał",
|
||||
"channels_createPrivateChannelDesc": "Zabezpieczone kluczem szyfrowym.",
|
||||
"channels_joinPrivateChannel": "Dołącz do Prywatnego Kanału",
|
||||
"channels_joinPublicChannel": "Dołącz do kanału publicznego.",
|
||||
"channels_joinPublicChannelDesc": "Każdy może dołączyć do tego kanału.",
|
||||
"channels_joinHashtagChannel": "Dołącz do kanału oznaczanego hashtagiem",
|
||||
"channels_joinHashtagChannelDesc": "Każdy może dołączyć do kanałów z hashtagami.",
|
||||
"channels_scanQrCode": "Skanuj kod QR",
|
||||
"channels_scanQrCodeComingSoon": "Wkrótce",
|
||||
"channels_enterHashtag": "Wprowadź hashtag",
|
||||
"channels_hashtagHint": "np. #zespół",
|
||||
"@neighbors_unknownContact": {
|
||||
"placeholders": {
|
||||
"pubkey": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@neighbors_heardAgo": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"neighbors_heardAgo": "Usłyszano: {time} temu",
|
||||
"neighbors_unknownContact": "Nieznana {pubkey}",
|
||||
"settings_locationGPSEnable": "Włącz GPS",
|
||||
"settings_locationGPSEnableSubtitle": "Włącza automatyczne aktualizowanie pozycji za pomocą GPS.",
|
||||
"settings_locationIntervalSec": "Interwał dla GPS (Sekundy)",
|
||||
"settings_locationIntervalInvalid": "Interwał musi wynosić co najmniej 60 sekund i mniej niż 86400 sekund.",
|
||||
"contacts_manageRoom": "Zarządzaj Serwerem Pokoju",
|
||||
"room_management": "Zarządzanie Serwerem Pokoju",
|
||||
"@community_joinConfirmation": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_created": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_joined": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_qrInstructions": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_alreadyMemberMessage": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleteConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleted": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_forCommunity": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_createDesc": "Utwórz nową społeczność i udostępnij za pomocą kodu QR.",
|
||||
"community_title": "Społeczność",
|
||||
"community_create": "Utwórz Społeczność",
|
||||
"common_ok": "OK",
|
||||
"community_join": "Dołącz",
|
||||
"community_joinTitle": "Dołącz do społeczności",
|
||||
"community_joinConfirmation": "Czy chcesz dołączyć do społeczności \"{name}\"?",
|
||||
"community_scanQr": "Skanuj QR kod społeczności",
|
||||
"community_scanInstructions": "Skieruj kamerę w kierunku kodu QR społeczności.",
|
||||
"community_showQr": "Pokaż kod QR",
|
||||
"community_publicChannel": "Społeczność Publiczna",
|
||||
"community_hashtagChannel": "Hashtag Społeczności",
|
||||
"community_name": "Nazwa Społeczności",
|
||||
"community_enterName": "Wprowadź nazwę społeczności",
|
||||
"community_created": "Społeczność \"{name}\" została utworzona",
|
||||
"community_joined": "Dołączył do społeczności \"{name}\"",
|
||||
"community_qrTitle": "Dziel się Społecznością",
|
||||
"community_qrInstructions": "Skanuj ten kod QR, aby dołączyć {name}",
|
||||
"community_hashtagPrivacyHint": "Kanały hashtagowe społeczności są dostępne tylko dla członków społeczności",
|
||||
"community_invalidQrCode": "Nieprawidłowy kod QR społeczności.",
|
||||
"community_alreadyMember": "Już jesteś członkiem.",
|
||||
"community_alreadyMemberMessage": "Jesteś już członkiem \"{name}\".",
|
||||
"community_addPublicChannel": "Dodaj Kanał Publiczny Społeczności",
|
||||
"community_addPublicChannelHint": "Automatycznie dodaj kanał publiczny dla tej społeczności.",
|
||||
"community_noCommunities": "Nie dołączono jeszcze żadnych społeczności.",
|
||||
"community_scanOrCreate": "Skanuj kod QR lub utwórz społeczność, aby zacząć.",
|
||||
"community_manageCommunities": "Zarządzaj Grupami",
|
||||
"community_delete": "Opuszczenie Społeczności",
|
||||
"community_deleteConfirm": "Opuścić \"{name}\"?",
|
||||
"community_deleteChannelsWarning": "Spowoduje to również usunięcie {count} kanału/kanałów i ich wiadomości.",
|
||||
"@community_deleteChannelsWarning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_deleted": "Opuszczono społeczność \"{name}\"",
|
||||
"community_addHashtagChannel": "Dodaj hashtag społeczności",
|
||||
"community_addHashtagChannelDesc": "Dodaj kanał z hashtagiem dla tej społeczności",
|
||||
"community_selectCommunity": "Wybierz społeczność",
|
||||
"community_regularHashtag": "Hashtag regular",
|
||||
"community_regularHashtagDesc": "Publiczny hashtag (każdy może dołączyć)",
|
||||
"community_communityHashtag": "Hashtag Społeczności",
|
||||
"community_communityHashtagDesc": "Dostępne tylko dla członków społeczności",
|
||||
"community_forCommunity": "Dla {name}",
|
||||
"@community_regenerateSecretConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretRegenerated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretUpdated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_scanToUpdateSecret": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_regenerate": "Zregeneruj",
|
||||
"community_secretRegenerated": "Hasło ponownie wygenerowane dla \"{name}\"",
|
||||
"community_regenerateSecret": "Zregeneruj sekret",
|
||||
"community_regenerateSecretConfirm": "Regeneruj tajny klucz dla \"{name}\"? Wszyscy członkowie będą musieli zeskanować nowy kod QR, aby kontynuować komunikację.",
|
||||
"community_scanToUpdateSecret": "Skanuj nowy kod QR, aby zaktualizować sekret dla \"{name}\"",
|
||||
"community_secretUpdated": "Hasło zaktualizowane dla \"{name}\"",
|
||||
"community_updateSecret": "Zaktualizuj tajny klucz",
|
||||
"@contacts_pathTraceTo": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pathTrace_you": "Ty",
|
||||
"pathTrace_failed": "Śledzenie ścieżki nie powiodło się.",
|
||||
"pathTrace_notAvailable": "Ścieżka śledzenia niedostępna.",
|
||||
"contacts_pathTrace": "Śledzenie Ścieżek",
|
||||
"contacts_ping": "Pingować",
|
||||
"contacts_repeaterPathTrace": "Śledzenie ścieżki do repeatera",
|
||||
"contacts_roomPathTrace": "Śledzenie ścieżki do serwera pokojowego",
|
||||
"contacts_roomPing": "Pinguj serwer pokoju",
|
||||
"pathTrace_refreshTooltip": "Odśwież ścieżkę.",
|
||||
"contacts_repeaterPing": "Repeater pingowy",
|
||||
"contacts_pathTraceTo": "Śledź trasę do {name}",
|
||||
"contacts_chatTraceRoute": "Śledź trasę promienia",
|
||||
"appSettings_languageRu": "Rosyjski",
|
||||
"appSettings_languageUk": "Ukraińska",
|
||||
"appSettings_enableMessageTracing": "Włącz śledzenie wiadomości",
|
||||
"appSettings_enableMessageTracingSubtitle": "Pokaż szczegółowe metadane trasowania i czasu dla wiadomości",
|
||||
"contacts_contactImportFailed": "Kontakt nie został zaimportowany.",
|
||||
"contacts_zeroHopAdvert": "Reklama Zero Hop",
|
||||
"contacts_floodAdvert": "Reklama powodziowa",
|
||||
"contacts_copyAdvertToClipboard": "Kopiuj ogłoszenie do schowka",
|
||||
"contacts_clipboardEmpty": "Schowek jest pusty.",
|
||||
"contacts_invalidAdvertFormat": "Nieprawidłowe dane kontaktowe",
|
||||
"contacts_addContactFromClipboard": "Dodaj kontakt z schowka",
|
||||
"contacts_contactImported": "Kontakt został zaimportowany.",
|
||||
"contacts_zeroHopContactAdvertSent": "Wysłano kontakt przez ogłoszenie.",
|
||||
"contacts_contactAdvertCopied": "Reklama skopiowana do schowka.",
|
||||
"contacts_contactAdvertCopyFailed": "Kopiowanie ogłoszenia do schowka nie powiodło się.",
|
||||
"contacts_ShareContactZeroHop": "Udostępnij kontakt przez ogłoszenie",
|
||||
"contacts_ShareContact": "Kopiuj kontakt do schowka",
|
||||
"contacts_zeroHopContactAdvertFailed": "Nie udało się wysłać kontaktu.",
|
||||
"notification_activityTitle": "Aktywność MeshCore",
|
||||
"notification_messagesCount": "{count} {count, plural, =1{wiadomość} few{wiadomości} many{wiadomości} other{wiadomości}}",
|
||||
"notification_channelMessagesCount": "{count} {count, plural, =1{wiadomość kanału} few{wiadomości kanału} many{wiadomości kanału} other{wiadomości kanału}}",
|
||||
"notification_newNodesCount": "{count} {count, plural, =1{nowy węzeł} few{nowe węzły} many{nowych węzłów} other{nowych węzłów}}",
|
||||
"notification_newTypeDiscovered": "Nowy {contactType} wykryty",
|
||||
"notification_receivedNewMessage": "Otrzymano nową wiadomość",
|
||||
"settings_gpxExportContacts": "Eksportuj towarzyszy do GPX",
|
||||
"settings_gpxExportRepeaters": "Eksportuj powtórki / serwer pokojowy do GPX",
|
||||
"settings_gpxExportRepeatersSubtitle": "Eksportuje powtarzacze / roomserver z lokalizacją do pliku GPX.",
|
||||
"settings_gpxExportSuccess": "Pomyślnie wyeksportowano plik GPX.",
|
||||
"settings_gpxExportNotAvailable": "Nie obsługiwane na Twoim urządzeniu/systemie operacyjnym",
|
||||
"settings_gpxExportError": "Wystąpił błąd podczas eksportowania.",
|
||||
"settings_gpxExportRepeatersRoom": "Lokalizacje serwerów powtarzających i pomieszczeń",
|
||||
"settings_gpxExportContactsSubtitle": "Eksportuje towarzyszy z lokalizacją do pliku GPX.",
|
||||
"settings_gpxExportAll": "Eksportuj wszystkie kontakty do GPX",
|
||||
"settings_gpxExportAllSubtitle": "Eksportuje wszystkie kontakty z lokalizacją do pliku GPX.",
|
||||
"settings_gpxExportAllContacts": "Wszystkie lokalizacje kontaktów",
|
||||
"settings_gpxExportNoContacts": "Brak kontaktów do wyeksportowania.",
|
||||
"settings_gpxExportChat": "Lokalizacje towarzyszy",
|
||||
"settings_gpxExportShareText": "Dane mapy wyeksportowane z meshcore-open",
|
||||
"settings_gpxExportShareSubject": "Eksport danych mapy GPX meshcore-open",
|
||||
"pathTrace_someHopsNoLocation": "Jeden lub więcej z chmieli nie ma określonej lokalizacji!",
|
||||
"map_pathTraceCancelled": "Śledzenie ścieżki anulowano.",
|
||||
"map_runTrace": "Uruchom ślad ścieżki",
|
||||
"pathTrace_clearTooltip": "Wyczyść ścieżkę",
|
||||
"map_removeLast": "Usuń ostatni",
|
||||
"map_tapToAdd": "Kliknij na węzły, aby dodać je do ścieżki.",
|
||||
"scanner_bluetoothOffMessage": "Prosimy włączyć Bluetooth, aby przeskanować urządzenia.",
|
||||
"scanner_chromeRequired": "Wymagana przeglądarka Chrome",
|
||||
"scanner_chromeRequiredMessage": "Ta aplikacja internetowa wymaga przeglądarki Google Chrome lub opartej na Chromium do obsługi Bluetooth.",
|
||||
"scanner_bluetoothOff": "Bluetooth jest wyłączony",
|
||||
"scanner_enableBluetooth": "Włącz Bluetooth",
|
||||
"snrIndicator_lastSeen": "Ostatnio widziany",
|
||||
"snrIndicator_nearByRepeaters": "Nadajniki w pobliżu",
|
||||
"chat_ShowAllPaths": "Pokaż wszystkie ścieżki",
|
||||
"settings_clientRepeatSubtitle": "Pozwól temu urządzeniu powtarzać pakiety danych dla innych urządzeń.",
|
||||
"settings_clientRepeat": "Powtórzenie: Niezależne od sieci",
|
||||
"settings_clientRepeatFreqWarning": "Powtórka poza siecią wymaga częstotliwości 433, 869 lub 918 MHz.",
|
||||
"settings_aboutOpenMeteoAttribution": "Dane wysokościowe LOS: Open-Meteo (CC BY 4.0)",
|
||||
"appSettings_unitsTitle": "Jednostki",
|
||||
"appSettings_unitsMetric": "Metryczne (m / km)",
|
||||
"appSettings_unitsImperial": "Imperialne (ft / mi)",
|
||||
"map_lineOfSight": "Linia wzroku",
|
||||
"map_losScreenTitle": "Linia wzroku",
|
||||
"losSelectStartEnd": "Wybierz węzły początkowe i końcowe dla LOS.",
|
||||
"losRunFailed": "Sprawdzenie pola widzenia nie powiodło się: {error}",
|
||||
"@losRunFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losClearAllPoints": "Wyczyść wszystkie punkty",
|
||||
"losRunToViewElevationProfile": "Uruchom LOS, aby wyświetlić profil wysokości",
|
||||
"losMenuTitle": "Menu LOS",
|
||||
"losMenuSubtitle": "Stuknij węzły lub naciśnij i przytrzymaj mapę, aby uzyskać niestandardowe punkty",
|
||||
"losShowDisplayNodes": "Pokaż węzły wyświetlające",
|
||||
"losCustomPoints": "Punkty niestandardowe",
|
||||
"losCustomPointLabel": "Niestandardowe {index}",
|
||||
"@losCustomPointLabel": {
|
||||
"placeholders": {
|
||||
"index": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losPointA": "Punkt A",
|
||||
"losPointB": "Punkt B",
|
||||
"losAntennaA": "Antena A: {value} {unit}",
|
||||
"@losAntennaA": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losAntennaB": "Antena B: {value} {unit}",
|
||||
"@losAntennaB": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losRun": "Uruchom LOS-a",
|
||||
"losNoElevationData": "Brak danych o wysokości",
|
||||
"losProfileClear": "{distance} {distanceUnit}, czysty LOS, minimalny prześwit {clearance} {heightUnit}",
|
||||
"@losProfileClear": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"clearance": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losProfileBlocked": "{distance} {distanceUnit}, zablokowane przez {obstruction} {heightUnit}",
|
||||
"@losProfileBlocked": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"obstruction": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losStatusChecking": "LOS: sprawdzam...",
|
||||
"losStatusNoData": "LOS: brak danych",
|
||||
"losStatusSummary": "LOS: {clear}/{total} jasne, {blocked} zablokowane, {unknown} nieznane",
|
||||
"@losStatusSummary": {
|
||||
"placeholders": {
|
||||
"clear": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "int"
|
||||
},
|
||||
"unknown": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losErrorElevationUnavailable": "Dane dotyczące wysokości są niedostępne dla jednej lub większej liczby próbek.",
|
||||
"losErrorInvalidInput": "Nieprawidłowe dane punktów/wysokości do obliczenia LOS.",
|
||||
"losRenameCustomPoint": "Zmień nazwę punktu niestandardowego",
|
||||
"losPointName": "Nazwa punktu",
|
||||
"losShowPanelTooltip": "Pokaż panel LOS",
|
||||
"losHidePanelTooltip": "Ukryj panel LOS",
|
||||
"losElevationAttribution": "Dane dotyczące wysokości: Open-Meteo (CC BY 4.0)",
|
||||
"losLegendRadioHorizon": "Horyzont radiowy",
|
||||
"losLegendLosBeam": "Linia widoczności",
|
||||
"losLegendTerrain": "Teren",
|
||||
"losFrequencyLabel": "Częstotliwość",
|
||||
"losFrequencyInfoTooltip": "Zobacz szczegóły obliczenia",
|
||||
"losFrequencyDialogTitle": "Obliczanie horyzontu radiowego",
|
||||
"losFrequencyDialogDescription": "Zaczynając od k={baselineK} przy {baselineFreq} MHz, obliczenia korygują współczynnik k dla bieżącego pasma {frequencyMHz} MHz, które definiuje zakrzywiony limit horyzontu radiowego.",
|
||||
"@losFrequencyDialogDescription": {
|
||||
"description": "Explain how the calculation uses the baseline frequency and derived k-factor.",
|
||||
"placeholders": {
|
||||
"baselineK": {
|
||||
"type": "double"
|
||||
},
|
||||
"baselineFreq": {
|
||||
"type": "double"
|
||||
},
|
||||
"frequencyMHz": {
|
||||
"type": "double"
|
||||
},
|
||||
"kFactor": {
|
||||
"type": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
"listFilter_removeFromFavorites": "Usuń z ulubionych",
|
||||
"listFilter_addToFavorites": "Dodaj do ulubionych",
|
||||
"listFilter_favorites": "Ulubione",
|
||||
"@contacts_searchFavorites": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchUsers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRepeaters": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRoomServers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contacts_unread": "Nieprzeczytane",
|
||||
"contacts_searchContactsNoNumber": "Wyszukaj kontakty...",
|
||||
"contacts_searchFavorites": "Wyszukaj {number}{str} ulubione...",
|
||||
"contacts_searchRoomServers": "Wyszukaj {number}{str} serwerów Room...",
|
||||
"contacts_searchUsers": "Wyszukaj {number}{str} Użytkowników...",
|
||||
"contacts_searchRepeaters": "Wyszukaj {number}{str} powtórników...",
|
||||
"contactsSettings_title": "Ustawienia kontaktów",
|
||||
"settings_contactSettingsSubtitle": "Ustawienia dotyczące sposobu dodawania kontaktów",
|
||||
"contactsSettings_autoAddUsersSubtitle": "Pozwól towarzyszowi automatycznie dodawać znalezione użytkowników.",
|
||||
"contactsSettings_autoAddRepeatersTitle": "Automatyczne dodawanie powtarzalników",
|
||||
"contactsSettings_autoAddRepeatersSubtitle": "Zezwól na automatyczne dodawanie odkrytych repeaterów przez towarzysza.",
|
||||
"contactsSettings_autoAddRoomServersTitle": "Automatycznie dodaj serwery pokojowe",
|
||||
"contactsSettings_autoAddUsersTitle": "Automatycznie dodaj użytkowników",
|
||||
"settings_contactSettings": "Ustawienia kontaktowe",
|
||||
"contactsSettings_otherTitle": "Inne ustawienia związane z kontaktami",
|
||||
"contactsSettings_autoAddTitle": "Automatyczne odnajdywanie",
|
||||
"contactsSettings_autoAddRoomServersSubtitle": "Zezwól towarzyszowi na automatyczne dodawanie znalezionych serwerów pokojowych.",
|
||||
"contactsSettings_autoAddSensorsTitle": "Automatycznie dodaj czujniki",
|
||||
"discoveredContacts_searchHint": "Wyszukaj odkryte kontakty",
|
||||
"discoveredContacts_contactAdded": "Kontakt dodany",
|
||||
"discoveredContacts_addContact": "Dodaj kontakt",
|
||||
"discoveredContacts_copyContact": "Kopiuj kontakt do schowka",
|
||||
"contactsSettings_overwriteOldestTitle": "Nadpisz najstarszy",
|
||||
"discoveredContacts_Title": "Odkryte Kontakty",
|
||||
"contactsSettings_autoAddSensorsSubtitle": "Zezwól towarzyszowi na automatyczne dodawanie wykrytych czujników.",
|
||||
"discoveredContacts_noMatching": "Brak pasujących kontaktów",
|
||||
"discoveredContacts_deleteContact": "Usuń kontakt",
|
||||
"contactsSettings_overwriteOldestSubtitle": "Gdy lista kontaktów jest pełna, najstarszy nieulubiony kontakt zostanie zastąpiony.",
|
||||
"common_deleteAll": "Usuń wszystko",
|
||||
"discoveredContacts_deleteContactAllContent": "Czy na pewno chcesz usunąć wszystkie znalezione kontakty?",
|
||||
"discoveredContacts_deleteContactAll": "Usuń wszystkie odkryte kontakty",
|
||||
"map_guessedLocation": "Wydana lokalizacja",
|
||||
"map_showGuessedLocations": "Wyświetl lokalizacje zgadanych węzłów",
|
||||
"usbScreenSubtitle": "Wybierz wykryty urządzenie szeregowe i podłącz je bezpośrednio do swojego węzła MeshCore.",
|
||||
"usbScreenTitle": "Połącz przez USB",
|
||||
"connectionChoiceUsbLabel": "USB",
|
||||
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||
"usbScreenStatus": "Wybierz urządzenie USB",
|
||||
"usbScreenNote": "Port szeregowy USB jest aktywny na urządzeniach z systemem Android i platformach stacjonarnych, które go obsługują.",
|
||||
"usbScreenEmptyState": "Nie znaleziono żadnych urządzeń USB. Podłącz jedno i zaktualizuj.",
|
||||
"usbErrorPermissionDenied": "Zostało odrzucone żądanie dostępu przez USB.",
|
||||
"usbErrorDeviceMissing": "Wybór urządzenia USB już nie jest dostępny.",
|
||||
"usbErrorInvalidPort": "Wybierz prawidłowe urządzenie USB.",
|
||||
"usbErrorBusy": "Kolejne żądanie połączenia przez USB jest już w trakcie realizacji.",
|
||||
"usbErrorNotConnected": "Brak podłączonego urządzenia USB.",
|
||||
"usbErrorOpenFailed": "Nie udało się otworzyć wybranego urządzenia USB.",
|
||||
"usbErrorConnectFailed": "Nie udało się połączyć z wybranym urządzeniem USB.",
|
||||
"usbErrorUnsupported": "Port szeregowy USB nie jest obsługiwany na tym urządzeniu.",
|
||||
"usbErrorAlreadyActive": "Połączenie USB jest już aktywne.",
|
||||
"usbErrorNoDeviceSelected": "Nie został wybrany żaden urządzenie USB.",
|
||||
"usbErrorPortClosed": "Połączenie USB nie jest aktywne.",
|
||||
"usbFallbackDeviceName": "Urządzenie do komunikacji przez sieć (seria)",
|
||||
"@usbConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"usbStatus_searching": "Wyszukiwanie urządzeń USB...",
|
||||
"usbStatus_connecting": "Połączenie z urządzeniem USB...",
|
||||
"usbStatus_notConnected": "Wybierz urządzenie USB",
|
||||
"usbConnectionFailed": "Błąd połączenia USB: {error}",
|
||||
"usbErrorConnectTimedOut": "Połączenie nie zostało nawiązane. Upewnij się, że urządzenie posiada oprogramowanie \"USB Companion\".",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpScreenTitle": "Połącz się za pomocą protokołu TCP",
|
||||
"tcpHostLabel": "Adres IP",
|
||||
"tcpPortLabel": "Port",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Wprowadź adres URL i połącz",
|
||||
"tcpStatus_connectingTo": "Połączenie z {endpoint}...",
|
||||
"tcpErrorHostRequired": "Wymagana jest adresa IP.",
|
||||
"tcpErrorPortInvalid": "Numer portu musi mieścić się w zakresie od 1 do 65535.",
|
||||
"tcpErrorUnsupported": "Transport protokoł TCP nie jest obsługiwany na tym urządzeniu.",
|
||||
"tcpErrorTimedOut": "Połączenie TCP zakończyło się bez powodzenia.",
|
||||
"tcpConnectionFailed": "Błąd połączenia TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Pokaż kontakty odkrywania",
|
||||
"map_setAsMyLocation": "Ustaw jako moje lokalizację",
|
||||
"settings_allowByContact": "Zezwalaj według flag kontaktowych",
|
||||
"settings_allowAll": "Zezwalaj na wszystko",
|
||||
"settings_telemetryLocationMode": "Tryb położenia telemetrycznego",
|
||||
"settings_telemetryEnvironmentMode": "Tryb środowiska telemetrycznego",
|
||||
"settings_advertLocation": "Lokalizacja reklamowa",
|
||||
"settings_advertLocationSubtitle": "Uwzględnij lokalizację w ogłoszeniu",
|
||||
"settings_denyAll": "Odmów wszystkim",
|
||||
"settings_privacySubtitle": "Kontroluj jakie informacje są udostępniane.",
|
||||
"settings_privacy": "Ustawienia prywatności",
|
||||
"settings_privacySettingsDescription": "Wybierz jakie informacje urządzenie udostępni innym.",
|
||||
"contact_info": "Informacje kontaktowe",
|
||||
"settings_telemetryBaseMode": "Tryb podstawowy telemetrii",
|
||||
"contact_teleBase": "Baza telemetryczna",
|
||||
"contact_teleLoc": "Lokalizacja telemetryczna",
|
||||
"contact_teleLocSubtitle": "Zezwalaj na udostępnianie danych lokalizacji",
|
||||
"contact_teleEnv": "Środowisko telemetryczne",
|
||||
"contact_teleEnvSubtitle": "Zezwalaj na udostępnianie danych czujników środowiskowych",
|
||||
"contact_telemetry": "Telemetryka",
|
||||
"contact_clearChat": "Wyczyść czat",
|
||||
"contact_settings": "Ustawienia kontaktowe",
|
||||
"contact_lastSeen": "Ostatnio widziany",
|
||||
"contact_teleBaseSubtitle": "Pozwól na udostępnianie poziomu naładowania baterii i podstawowych danych telemetrycznych",
|
||||
"@settings_multiAck": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings_telemetryModeUpdated": "Tryb telemetryczny zaktualizowany",
|
||||
"settings_multiAck": "Wiele potwierdzeń: {value}"
|
||||
}
|
||||
|
||||
+591
-6
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"channels_channelDeleteFailed": "Falha ao excluir o canal \"{name}\"",
|
||||
"@channels_channelDeleteFailed": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@@locale": "pt",
|
||||
"appTitle": "MeshCore Open",
|
||||
"nav_contacts": "Contactos",
|
||||
@@ -131,9 +139,6 @@
|
||||
"settings_infoContactsCount": "Número de Contatos",
|
||||
"settings_infoChannelCount": "Número do Canal",
|
||||
"settings_presets": "Presets",
|
||||
"settings_preset915Mhz": "915 MHz",
|
||||
"settings_preset868Mhz": "868 MHz",
|
||||
"settings_preset433Mhz": "433 MHz",
|
||||
"settings_frequency": "Frequência (MHz)",
|
||||
"settings_frequencyHelper": "300,0 - 2500,0",
|
||||
"settings_frequencyInvalid": "Frequência inválida (300-2500 MHz)",
|
||||
@@ -143,8 +148,6 @@
|
||||
"settings_txPower": "TX Potência (dBm)",
|
||||
"settings_txPowerHelper": "0 - 22",
|
||||
"settings_txPowerInvalid": "Potência de TX inválida (0-22 dBm)",
|
||||
"settings_longRange": "Alcance Longo",
|
||||
"settings_fastSpeed": "Velocidade Rápida",
|
||||
"settings_error": "Erro: {message}",
|
||||
"@settings_error": {
|
||||
"placeholders": {
|
||||
@@ -282,6 +285,7 @@
|
||||
"contacts_newGroup": "Novo Grupo",
|
||||
"contacts_groupName": "Nome do grupo",
|
||||
"contacts_groupNameRequired": "O nome do grupo é obrigatório.",
|
||||
"contacts_groupNameReserved": "Este nome de grupo está reservado",
|
||||
"contacts_groupAlreadyExists": "O grupo \"{name}\" já existe",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
@@ -339,6 +343,8 @@
|
||||
"channels_publicChannel": "Canal público",
|
||||
"channels_privateChannel": "Canal privado",
|
||||
"channels_editChannel": "Editar canal",
|
||||
"channels_muteChannel": "Silenciar canal",
|
||||
"channels_unmuteChannel": "Ativar canal",
|
||||
"channels_deleteChannel": "Excluir canal",
|
||||
"channels_deleteChannelConfirm": "Excluir \"{name}\"? Não pode ser desfeito.",
|
||||
"@channels_deleteChannelConfirm": {
|
||||
@@ -604,6 +610,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_openLink": "Abrir link?",
|
||||
"chat_openLinkConfirmation": "Deseja abrir este link no seu navegador?",
|
||||
"chat_open": "Abrir",
|
||||
"chat_couldNotOpenLink": "Não foi possível abrir o link: {url}",
|
||||
"@chat_couldNotOpenLink": {
|
||||
"placeholders": {
|
||||
"url": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_invalidLink": "Formato de link inválido",
|
||||
"map_title": "Mapa de Nós",
|
||||
"map_noNodesWithLocation": "Não existem nós com dados de localização.",
|
||||
"map_nodesNeedGps": "Os nós precisam partilhar as suas coordenadas GPS\npara aparecerem no mapa",
|
||||
@@ -821,6 +839,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"login_failedMessage": "Falha no login. A senha está incorreta ou o repetidor está inacessível.",
|
||||
"common_reload": "Recarregar",
|
||||
"common_clear": "Limpar",
|
||||
"path_currentPath": "Caminho atual: {path}",
|
||||
@@ -1335,5 +1354,571 @@
|
||||
"listFilter_repeaters": "Repetidores",
|
||||
"listFilter_roomServers": "Servidores de sala",
|
||||
"listFilter_unreadOnly": "Apenas não lido",
|
||||
"listFilter_newGroup": "Novo grupo"
|
||||
"listFilter_newGroup": "Novo grupo",
|
||||
"@neighbors_errorLoading": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_neighbors": "Vizinhos",
|
||||
"neighbors_receivedData": "Dados dos Vizinhos Recebidos",
|
||||
"repeater_neighborsSubtitle": "Visualizar vizinhos de salto zero.",
|
||||
"neighbors_requestTimedOut": "Vizinhos solicitam tempo limite esgotado.",
|
||||
"neighbors_errorLoading": "Erro ao carregar vizinhos: {error}",
|
||||
"neighbors_repeatersNeighbors": "Repetidores Vizinhos",
|
||||
"neighbors_noData": "Não estão disponíveis dados de vizinhos.",
|
||||
"channels_createPrivateChannelDesc": "Protegido com uma chave secreta.",
|
||||
"channels_joinPrivateChannelDesc": "Inserir uma chave secreta manualmente.",
|
||||
"channels_createPrivateChannel": "Criar um Canal Privado",
|
||||
"channels_joinPrivateChannel": "Junte-se a um Canal Privado",
|
||||
"channels_joinPublicChannel": "Junte-se ao Canal Público",
|
||||
"channels_joinPublicChannelDesc": "Qualquer pessoa pode entrar neste canal.",
|
||||
"channels_joinHashtagChannel": "Junte-se a um Canal com Hashtag",
|
||||
"channels_joinHashtagChannelDesc": "Qualquer pessoa pode participar de canais com hashtag.",
|
||||
"channels_scanQrCode": "Digitalizar um Código QR",
|
||||
"channels_scanQrCodeComingSoon": "Em breve",
|
||||
"channels_enterHashtag": "Insira hashtag",
|
||||
"channels_hashtagHint": "ex. #equipe",
|
||||
"@neighbors_unknownContact": {
|
||||
"placeholders": {
|
||||
"pubkey": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@neighbors_heardAgo": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"neighbors_heardAgo": "Ouvido: {time} atrás",
|
||||
"neighbors_unknownContact": "{pubkey} Desconhecido",
|
||||
"settings_locationGPSEnable": "Ativar GPS",
|
||||
"settings_locationGPSEnableSubtitle": "Habilita a atualização automática da localização via GPS.",
|
||||
"settings_locationIntervalInvalid": "O intervalo deve ser de pelo menos 60 segundos e inferior a 86400 segundos.",
|
||||
"settings_locationIntervalSec": "Intervalo para GPS (Segundos)",
|
||||
"contacts_manageRoom": "Gerenciar Servidor de Sala",
|
||||
"room_management": "Gerenciamento de Servidor de Sala",
|
||||
"@community_joinConfirmation": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_created": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_joined": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_qrInstructions": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_alreadyMemberMessage": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleteConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleted": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_forCommunity": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_title": "Comunidade",
|
||||
"community_createDesc": "Crie uma nova comunidade e compartilhe via código QR.",
|
||||
"common_ok": "OK",
|
||||
"community_create": "Criar Comunidade",
|
||||
"community_join": "Junte-se",
|
||||
"community_joinTitle": "Junte-se à Comunidade",
|
||||
"community_joinConfirmation": "Você gostaria de se juntar à comunidade \"{name}\"?",
|
||||
"community_scanQr": "Digitalizar a QR Code da Comunidade",
|
||||
"community_scanInstructions": "Aponte a câmera para um código QR da comunidade",
|
||||
"community_showQr": "Mostrar Código QR",
|
||||
"community_publicChannel": "Comunidade Pública",
|
||||
"community_hashtagChannel": "Hashtag da Comunidade",
|
||||
"community_name": "Nome da Comunidade",
|
||||
"community_enterName": "Insira o nome da comunidade",
|
||||
"community_created": "Comunidade \"{name}\" criada",
|
||||
"community_joined": "Juntou-se à comunidade \"{name}\"",
|
||||
"community_qrTitle": "Partilhar Comunidade",
|
||||
"community_qrInstructions": "Escanear este código QR para juntar-se a {name}",
|
||||
"community_hashtagPrivacyHint": "Os canais de hashtag da comunidade só podem ser acessados por membros da comunidade",
|
||||
"community_invalidQrCode": "Código QR da comunidade inválido",
|
||||
"community_alreadyMember": "Já é Membro",
|
||||
"community_alreadyMemberMessage": "Você já é membro de \"{name}\".",
|
||||
"community_addPublicChannel": "Adicionar Canal Público da Comunidade",
|
||||
"community_addPublicChannelHint": "Adicionar automaticamente o canal público para esta comunidade",
|
||||
"community_noCommunities": "Ainda não foram adicionadas comunidades.",
|
||||
"community_scanOrCreate": "Escaneie um código QR ou crie uma comunidade para começar.",
|
||||
"community_manageCommunities": "Gerenciar Comunidades",
|
||||
"community_delete": "Deixar Comunidade",
|
||||
"community_deleteConfirm": "Sair de \"{name}\"?",
|
||||
"community_deleteChannelsWarning": "Isso também excluirá {count} canal/canais e suas mensagens.",
|
||||
"@community_deleteChannelsWarning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_deleted": "Saiu da comunidade \"{name}\"",
|
||||
"community_addHashtagChannel": "Adicionar Hashtag da Comunidade",
|
||||
"community_addHashtagChannelDesc": "Adicionar um canal de hashtag para esta comunidade",
|
||||
"community_selectCommunity": "Selecione Comunidade",
|
||||
"community_regularHashtag": "Hashtag Regular",
|
||||
"community_regularHashtagDesc": "Hashtag público (qualquer pessoa pode participar)",
|
||||
"community_communityHashtag": "Hashtag da Comunidade",
|
||||
"community_communityHashtagDesc": "Apenas para membros da comunidade",
|
||||
"community_forCommunity": "Para {name}",
|
||||
"@community_regenerateSecretConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretRegenerated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretUpdated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_scanToUpdateSecret": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_regenerateSecretConfirm": "Regenerar a chave secreta para \"{name}\"? Todos os membros precisarão escanear o novo código QR para continuar a comunicação.",
|
||||
"community_regenerateSecret": "Regenerar Senha Segura",
|
||||
"community_secretRegenerated": "Senha secreta regenerada para \"{name}\"",
|
||||
"community_regenerate": "Regenerar",
|
||||
"community_secretUpdated": "Segredo atualizado para \"{name}\"",
|
||||
"community_scanToUpdateSecret": "Scanar o novo código QR para atualizar o segredo para \"{name}\"\n\n\n+++++",
|
||||
"community_updateSecret": "Atualizar Segredo",
|
||||
"@contacts_pathTraceTo": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pathTrace_you": "Você",
|
||||
"pathTrace_failed": "Falha no rastreamento de caminho.",
|
||||
"pathTrace_notAvailable": "Traçado de caminho não disponível.",
|
||||
"pathTrace_refreshTooltip": "Atualizar Path Trace.",
|
||||
"contacts_pathTrace": "Traçado de Caminho",
|
||||
"contacts_ping": "Pingar",
|
||||
"contacts_repeaterPathTrace": "Traçar caminho para repetidor",
|
||||
"contacts_repeaterPing": "Pingar repetidor",
|
||||
"contacts_roomPathTrace": "Traçar caminho para o servidor da sala",
|
||||
"contacts_roomPing": "Pingar servidor da sala",
|
||||
"contacts_chatTraceRoute": "Rastrear rota do caminho",
|
||||
"contacts_pathTraceTo": "Rastrear rota para {name}",
|
||||
"contacts_invalidAdvertFormat": "Dados de Contato Inválidos",
|
||||
"contacts_clipboardEmpty": "Área de Transferência Está Vazia.",
|
||||
"appSettings_languageUk": "Ucraniano",
|
||||
"contacts_contactImported": "Contato foi importado.",
|
||||
"contacts_zeroHopAdvert": "Anúncio Zero Hop",
|
||||
"contacts_copyAdvertToClipboard": "Copiar Anúncio para Área de Transferência",
|
||||
"contacts_addContactFromClipboard": "Adicionar Contato da Área de Transferência",
|
||||
"appSettings_languageRu": "Russo",
|
||||
"appSettings_enableMessageTracing": "Ativar rastreamento de mensagens",
|
||||
"appSettings_enableMessageTracingSubtitle": "Mostrar metadados detalhados de roteamento e tempo para as mensagens",
|
||||
"contacts_ShareContact": "Copiar contato para Área de Transferência",
|
||||
"contacts_contactImportFailed": "Contato falhou ao ser importado.",
|
||||
"contacts_zeroHopContactAdvertSent": "Enviou contato por anúncio.",
|
||||
"contacts_contactAdvertCopied": "Anúncio copiado para a Área de Transferência.",
|
||||
"contacts_floodAdvert": "Anúncio de Inundação",
|
||||
"contacts_contactAdvertCopyFailed": "Cópia do anúncio para a Área de Transferência falhou.",
|
||||
"contacts_ShareContactZeroHop": "Compartilhar contato por anúncio",
|
||||
"contacts_zeroHopContactAdvertFailed": "Falha ao enviar contato.",
|
||||
"notification_activityTitle": "Atividade MeshCore",
|
||||
"notification_messagesCount": "{count} {count, plural, =1{mensagem} other{mensagens}}",
|
||||
"notification_channelMessagesCount": "{count} {count, plural, =1{mensagem de canal} other{mensagens de canal}}",
|
||||
"notification_newNodesCount": "{count} {count, plural, =1{novo nó} other{novos nós}}",
|
||||
"notification_newTypeDiscovered": "Novo {contactType} descoberto",
|
||||
"notification_receivedNewMessage": "Nova mensagem recebida",
|
||||
"settings_gpxExportRepeaters": "Exportar repetidores / servidor de sala para GPX",
|
||||
"settings_gpxExportRepeatersSubtitle": "Exporta repetidores / roomserver com localização para arquivo GPX.",
|
||||
"settings_gpxExportSuccess": "Arquivo GPX exportado com sucesso.",
|
||||
"settings_gpxExportAllSubtitle": "Exporta todos os contatos com uma localização para um arquivo GPX.",
|
||||
"settings_gpxExportNotAvailable": "Não suportado no seu dispositivo/SO",
|
||||
"settings_gpxExportError": "Ocorreu um erro ao exportar.",
|
||||
"settings_gpxExportAll": "Exportar todos os contatos para GPX",
|
||||
"settings_gpxExportContacts": "Exportar companheiros para GPX",
|
||||
"settings_gpxExportContactsSubtitle": "Exporta companheiros com uma localização para um arquivo GPX.",
|
||||
"settings_gpxExportRepeatersRoom": "Localizações do servidor de repetidor e sala",
|
||||
"settings_gpxExportChat": "Localizações de companheiros",
|
||||
"settings_gpxExportNoContacts": "Nenhum contato para exportar.",
|
||||
"settings_gpxExportAllContacts": "Todos os locais de contatos",
|
||||
"settings_gpxExportShareText": "Dados do mapa exportados do meshcore-open",
|
||||
"settings_gpxExportShareSubject": "meshcore-open exportação de dados de mapa GPX",
|
||||
"pathTrace_someHopsNoLocation": "Um ou mais dos lúpulos estão sem localização!",
|
||||
"map_runTrace": "Executar Traçado de Caminho",
|
||||
"map_pathTraceCancelled": "Rastreamento de caminho cancelado.",
|
||||
"pathTrace_clearTooltip": "Limpar caminho",
|
||||
"map_removeLast": "Remover Último",
|
||||
"map_tapToAdd": "Toque nos nós para adicioná-los ao caminho.",
|
||||
"scanner_enableBluetooth": "Ative o Bluetooth",
|
||||
"scanner_bluetoothOff": "Bluetooth está desativado",
|
||||
"scanner_bluetoothOffMessage": "Por favor, ative o Bluetooth para escanear por dispositivos.",
|
||||
"scanner_chromeRequired": "Navegador Chrome necessário",
|
||||
"scanner_chromeRequiredMessage": "Esta aplicação web requer o Google Chrome ou um navegador baseado no Chromium para suporte de Bluetooth.",
|
||||
"snrIndicator_nearByRepeaters": "Repetidores Próximos",
|
||||
"snrIndicator_lastSeen": "Visto pela última vez",
|
||||
"chat_ShowAllPaths": "Mostrar todos os caminhos",
|
||||
"settings_clientRepeatFreqWarning": "A repetição fora da rede requer frequências de 433, 869 ou 918 MHz.",
|
||||
"settings_clientRepeat": "Repetição sem rede",
|
||||
"settings_clientRepeatSubtitle": "Permita que este dispositivo repita pacotes de rede para outros dispositivos.",
|
||||
"settings_aboutOpenMeteoAttribution": "Dados de elevação LOS: Open-Meteo (CC BY 4.0)",
|
||||
"appSettings_unitsTitle": "Unidades",
|
||||
"appSettings_unitsMetric": "Métrico (m/km)",
|
||||
"appSettings_unitsImperial": "Imperial (ft/mi)",
|
||||
"map_lineOfSight": "Linha de visão",
|
||||
"map_losScreenTitle": "Linha de visão",
|
||||
"losSelectStartEnd": "Selecione nós iniciais e finais para LOS.",
|
||||
"losRunFailed": "Falha na verificação da linha de visão: {error}",
|
||||
"@losRunFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losClearAllPoints": "Limpe todos os pontos",
|
||||
"losRunToViewElevationProfile": "Execute o LOS para visualizar o perfil de elevação",
|
||||
"losMenuTitle": "Menu LOS",
|
||||
"losMenuSubtitle": "Toque nos nós ou mantenha pressionado o mapa para obter pontos personalizados",
|
||||
"losShowDisplayNodes": "Mostrar nós de exibição",
|
||||
"losCustomPoints": "Pontos personalizados",
|
||||
"losCustomPointLabel": "{index} personalizado",
|
||||
"@losCustomPointLabel": {
|
||||
"placeholders": {
|
||||
"index": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losPointA": "Ponto A",
|
||||
"losPointB": "Ponto B",
|
||||
"losAntennaA": "Antena A: {value} {unit}",
|
||||
"@losAntennaA": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losAntennaB": "Antena B: {value} {unit}",
|
||||
"@losAntennaB": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losRun": "Executar LOS",
|
||||
"losNoElevationData": "Sem dados de elevação",
|
||||
"losProfileClear": "{distance} {distanceUnit}, limpar LOS, liberação mínima {clearance} {heightUnit}",
|
||||
"@losProfileClear": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"clearance": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losProfileBlocked": "{distance} {distanceUnit}, bloqueado por {obstruction} {heightUnit}",
|
||||
"@losProfileBlocked": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"obstruction": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losStatusChecking": "LOS: verificando...",
|
||||
"losStatusNoData": "LOS: sem dados",
|
||||
"losStatusSummary": "LOS: {clear}/{total} limpo, {blocked} bloqueado, {unknown} desconhecido",
|
||||
"@losStatusSummary": {
|
||||
"placeholders": {
|
||||
"clear": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "int"
|
||||
},
|
||||
"unknown": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losErrorElevationUnavailable": "Dados de elevação indisponíveis para uma ou mais amostras.",
|
||||
"losErrorInvalidInput": "Dados de pontos/elevação inválidos para cálculo de LOS.",
|
||||
"losRenameCustomPoint": "Renomear ponto personalizado",
|
||||
"losPointName": "Nome do ponto",
|
||||
"losShowPanelTooltip": "Mostrar painel LOS",
|
||||
"losHidePanelTooltip": "Ocultar painel LOS",
|
||||
"losElevationAttribution": "Dados de elevação: Open-Meteo (CC BY 4.0)",
|
||||
"losLegendRadioHorizon": "Horizonte de rádio",
|
||||
"losLegendLosBeam": "Linha de visada",
|
||||
"losLegendTerrain": "Terreno",
|
||||
"losFrequencyLabel": "Frequência",
|
||||
"losFrequencyInfoTooltip": "Ver detalhes do cálculo",
|
||||
"losFrequencyDialogTitle": "Cálculo do horizonte de rádio",
|
||||
"losFrequencyDialogDescription": "Começando em k={baselineK} em {baselineFreq} MHz, o cálculo ajusta o fator k para a banda atual de {frequencyMHz} MHz, que define o limite do horizonte de rádio curvo.",
|
||||
"@losFrequencyDialogDescription": {
|
||||
"description": "Explain how the calculation uses the baseline frequency and derived k-factor.",
|
||||
"placeholders": {
|
||||
"baselineK": {
|
||||
"type": "double"
|
||||
},
|
||||
"baselineFreq": {
|
||||
"type": "double"
|
||||
},
|
||||
"frequencyMHz": {
|
||||
"type": "double"
|
||||
},
|
||||
"kFactor": {
|
||||
"type": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
"listFilter_addToFavorites": "Adicionar aos favoritos",
|
||||
"listFilter_removeFromFavorites": "Remover da lista de favoritos",
|
||||
"listFilter_favorites": "Favoritos",
|
||||
"@contacts_searchFavorites": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchUsers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRepeaters": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRoomServers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contacts_searchRepeaters": "Pesquisar {number}{str} Repetidores...",
|
||||
"contacts_searchFavorites": "Pesquisar {number}{str} Favoritos...",
|
||||
"contacts_searchUsers": "Pesquisar {number}{str} Usuários...",
|
||||
"contacts_searchContactsNoNumber": "Pesquisar Contatos...",
|
||||
"contacts_unread": "Não lido",
|
||||
"contacts_searchRoomServers": "Pesquisar {number}{str} servidores de sala...",
|
||||
"settings_contactSettings": "Configurações de Contato",
|
||||
"contactsSettings_otherTitle": "Outras configurações relacionadas a contatos",
|
||||
"contactsSettings_title": "Configurações de contatos",
|
||||
"contactsSettings_autoAddTitle": "Descoberta Automática",
|
||||
"settings_contactSettingsSubtitle": "Configurações para como os contatos são adicionados",
|
||||
"contactsSettings_autoAddUsersTitle": "Adicionar usuários automaticamente",
|
||||
"contactsSettings_autoAddRepeatersSubtitle": "Permitir que o companheiro adicione automaticamente os repetidores descobertos.",
|
||||
"contactsSettings_autoAddRoomServersTitle": "Adicionar automaticamente servidores de sala",
|
||||
"contactsSettings_overwriteOldestTitle": "Sobrescrever o Mais Antigo",
|
||||
"contactsSettings_autoAddSensorsTitle": "Adicionar sensores automaticamente",
|
||||
"discoveredContacts_Title": "Contatos Descobertos",
|
||||
"contactsSettings_autoAddUsersSubtitle": "Permitir que o companheiro adicione automaticamente os usuários descobertos.",
|
||||
"contactsSettings_autoAddRepeatersTitle": "Adicionar repetidores automaticamente",
|
||||
"discoveredContacts_noMatching": "Nenhum contato correspondente",
|
||||
"contactsSettings_autoAddRoomServersSubtitle": "Permitir que o companheiro adicione automaticamente os servidores de salas descobertos.",
|
||||
"discoveredContacts_searchHint": "Pesquisar contatos descobertos",
|
||||
"contactsSettings_autoAddSensorsSubtitle": "Permitir que o companheiro adicione automaticamente sensores descobertos.",
|
||||
"discoveredContacts_copyContact": "Copiar Contato para a área de transferência",
|
||||
"discoveredContacts_deleteContact": "Excluir Contato",
|
||||
"discoveredContacts_contactAdded": "Contato adicionado",
|
||||
"discoveredContacts_addContact": "Adicionar Contato",
|
||||
"contactsSettings_overwriteOldestSubtitle": "Quando a lista de contatos estiver cheia, o contato mais antigo não favoritado será substituído.",
|
||||
"common_deleteAll": "Excluir Tudo",
|
||||
"discoveredContacts_deleteContactAll": "Excluir Todos os Contatos Descobertos",
|
||||
"discoveredContacts_deleteContactAllContent": "Tem certeza de que deseja excluir todos os contatos descobertos?",
|
||||
"map_guessedLocation": "Localização estimada",
|
||||
"map_showGuessedLocations": "Mostrar as localizações dos nós estimados",
|
||||
"connectionChoiceUsbLabel": "USB",
|
||||
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||
"usbScreenTitle": "Conecte via USB",
|
||||
"usbScreenSubtitle": "Selecione um dispositivo serial detectado e conecte-o diretamente ao seu nó MeshCore.",
|
||||
"usbScreenStatus": "Selecione um dispositivo USB",
|
||||
"usbScreenNote": "A comunicação serial via USB está ativa em dispositivos Android e plataformas de desktop compatíveis.",
|
||||
"usbScreenEmptyState": "Nenhum dispositivo USB encontrado. Conecte um e atualize.",
|
||||
"usbErrorPermissionDenied": "A permissão para acesso via USB foi negada.",
|
||||
"usbErrorDeviceMissing": "O dispositivo USB selecionado não está mais disponível.",
|
||||
"usbErrorInvalidPort": "Selecione um dispositivo USB válido.",
|
||||
"usbErrorBusy": "Já existe uma solicitação de conexão USB em andamento.",
|
||||
"usbErrorNotConnected": "Não há nenhum dispositivo USB conectado.",
|
||||
"usbErrorOpenFailed": "Não foi possível abrir o dispositivo USB selecionado.",
|
||||
"usbErrorConnectFailed": "Não foi possível conectar ao dispositivo USB selecionado.",
|
||||
"usbErrorUnsupported": "A comunicação serial via USB não é suportada nesta plataforma.",
|
||||
"usbErrorAlreadyActive": "A conexão USB já está ativa.",
|
||||
"usbErrorNoDeviceSelected": "Nenhum dispositivo USB foi selecionado.",
|
||||
"usbErrorPortClosed": "A conexão USB não está ativa.",
|
||||
"usbFallbackDeviceName": "Dispositivo de Serial para a Web",
|
||||
"@usbConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"usbStatus_searching": "Procurando por dispositivos USB...",
|
||||
"usbStatus_notConnected": "Selecione um dispositivo USB",
|
||||
"usbConnectionFailed": "Falha na conexão USB: {error}",
|
||||
"usbStatus_connecting": "Conectando ao dispositivo USB...",
|
||||
"usbErrorConnectTimedOut": "A conexão expirou. Verifique se o dispositivo possui o firmware USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostLabel": "Endereço IP",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpScreenTitle": "Estabelecer conexão via TCP",
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpPortLabel": "Porta",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Insira o endereço final e conecte-se.",
|
||||
"tcpStatus_connectingTo": "Conectando a {endpoint}...",
|
||||
"tcpErrorHostRequired": "É necessário fornecer um endereço IP.",
|
||||
"tcpErrorPortInvalid": "O valor do porto deve estar entre 1 e 65535.",
|
||||
"tcpErrorUnsupported": "O protocolo TCP não é suportado nesta plataforma.",
|
||||
"tcpErrorTimedOut": "A conexão TCP expirou.",
|
||||
"tcpConnectionFailed": "Falha na conexão TCP: {error}",
|
||||
"map_showDiscoveryContacts": "Mostrar Contatos de Descoberta",
|
||||
"map_setAsMyLocation": "Defina minha localização",
|
||||
"settings_privacySettingsDescription": "Escolha quais informações o seu dispositivo compartilha com os outros.",
|
||||
"settings_allowByContact": "Permitir por bandeiras de contato",
|
||||
"settings_telemetryLocationMode": "Modo de Localização de Telemetria",
|
||||
"settings_telemetryEnvironmentMode": "Modo de Ambiente de Telemetria",
|
||||
"settings_advertLocation": "Localização do Anúncio",
|
||||
"settings_advertLocationSubtitle": "Incluir localização no anúncio",
|
||||
"settings_privacySubtitle": "Controle o que é compartilhado.",
|
||||
"settings_denyAll": "Negar todos",
|
||||
"settings_allowAll": "Permitir todos",
|
||||
"settings_privacy": "Configurações de Privacidade",
|
||||
"contact_info": "Informações de Contato",
|
||||
"settings_telemetryBaseMode": "Modo Base de Telemetria",
|
||||
"contact_teleBase": "Base de Telemetria",
|
||||
"contact_teleLoc": "Localização de Telemetria",
|
||||
"contact_teleLocSubtitle": "Permitir compartilhamento de dados de localização",
|
||||
"contact_teleEnv": "Ambiente de Telemetria",
|
||||
"contact_teleEnvSubtitle": "Permitir compartilhamento de dados do sensor de ambiente",
|
||||
"contact_lastSeen": "Visto pela última vez",
|
||||
"contact_clearChat": "Limpar Chat",
|
||||
"contact_telemetry": "Telemetria",
|
||||
"contact_settings": "Configurações de Contato",
|
||||
"contact_teleBaseSubtitle": "Permitir compartilhamento do nível da bateria e telemetria básica",
|
||||
"@settings_multiAck": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings_telemetryModeUpdated": "Modo de telemetria atualizado",
|
||||
"settings_multiAck": "Multi-ACKs: {value}"
|
||||
}
|
||||
|
||||
+1164
File diff suppressed because it is too large
Load Diff
+592
-7
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"channels_channelDeleteFailed": "Kanál \"{name}\" sa nepodarilo odstrániť",
|
||||
"@channels_channelDeleteFailed": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@@locale": "sk",
|
||||
"appTitle": "MeshCore Open",
|
||||
"nav_contacts": "Kontakty",
|
||||
@@ -131,9 +139,6 @@
|
||||
"settings_infoContactsCount": "Počet kontaktov",
|
||||
"settings_infoChannelCount": "Počet kanálov",
|
||||
"settings_presets": "Prednastavenia",
|
||||
"settings_preset915Mhz": "915 MHz",
|
||||
"settings_preset868Mhz": "868 MHz",
|
||||
"settings_preset433Mhz": "433 MHz",
|
||||
"settings_frequency": "Frekvencia (MHz)",
|
||||
"settings_frequencyHelper": "300,0 – 2500,0",
|
||||
"settings_frequencyInvalid": "Neplatná frekvencia (300-2500 MHz)",
|
||||
@@ -143,8 +148,6 @@
|
||||
"settings_txPower": "TX Výkon (dBm)",
|
||||
"settings_txPowerHelper": "0 - 22",
|
||||
"settings_txPowerInvalid": "Neplatná hodnota výkonu TX (0-22 dBm)",
|
||||
"settings_longRange": "Dlhý dosah",
|
||||
"settings_fastSpeed": "Rýchla rýchlosť",
|
||||
"settings_error": "Chyba: {message}",
|
||||
"@settings_error": {
|
||||
"placeholders": {
|
||||
@@ -282,6 +285,7 @@
|
||||
"contacts_newGroup": "Nová skupina",
|
||||
"contacts_groupName": "Názov skupiny",
|
||||
"contacts_groupNameRequired": "Skupina musí mať názov.",
|
||||
"contacts_groupNameReserved": "Tento názov skupiny je rezervovaný",
|
||||
"contacts_groupAlreadyExists": "Skupina \"{name}\" už existuje",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
@@ -339,6 +343,8 @@
|
||||
"channels_publicChannel": "Veľké verejne kanály",
|
||||
"channels_privateChannel": "Osobné kanál",
|
||||
"channels_editChannel": "Upraviť kanál",
|
||||
"channels_muteChannel": "Stlmiť kanál",
|
||||
"channels_unmuteChannel": "Zrušiť stlmenie kanála",
|
||||
"channels_deleteChannel": "Odstrániť kanál",
|
||||
"channels_deleteChannelConfirm": "Odstrániť \"{name}\"? To sa nedá zrušiť.",
|
||||
"@channels_deleteChannelConfirm": {
|
||||
@@ -559,7 +565,7 @@
|
||||
"chat_setCustomPath": "Nastaviť vlastnú cestu",
|
||||
"chat_setCustomPathSubtitle": "Ručne zadajte trasu.",
|
||||
"chat_clearPath": "Vyčistiš cestu",
|
||||
"chat_clearPathSubtitle": "Znovu nájsť vynútene pri nasledujacej pošlite",
|
||||
"chat_clearPathSubtitle": "Znovu nájsť vynútene pri nasledujúcej pošlite",
|
||||
"chat_pathCleared": "Cesta vyčistená. Nasledujúce prepočetné získa trasu znova.",
|
||||
"chat_floodModeSubtitle": "Použite prepínanie trasy v navigačnom paneli.",
|
||||
"chat_floodModeEnabled": "Odosporňovacia prevádzka je zapnutá. Vypnite ju znova cez ikonu routovania v navigačnom páse.",
|
||||
@@ -604,6 +610,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_openLink": "Otvoriť odkaz?",
|
||||
"chat_openLinkConfirmation": "Chcete otvoriť tento odkaz v prehliadači?",
|
||||
"chat_open": "Otvoriť",
|
||||
"chat_couldNotOpenLink": "Nepodarilo sa otvoriť odkaz: {url}",
|
||||
"@chat_couldNotOpenLink": {
|
||||
"placeholders": {
|
||||
"url": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_invalidLink": "Neplatný formát odkazu",
|
||||
"map_title": "Mapa uzlov",
|
||||
"map_noNodesWithLocation": "Žiadne uzly s údajmi o polohe",
|
||||
"map_nodesNeedGps": "Uholníky musia zdieľať svoje GPS súradnice, aby sa zobrazili na mape.",
|
||||
@@ -821,6 +839,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"login_failedMessage": "Prihlásenie zlyhalo. Heslo je nesprávne alebo je opakovač nedostupný.",
|
||||
"common_reload": "Načítať",
|
||||
"common_clear": "Zmazať",
|
||||
"path_currentPath": "Aktívna cesta: {path}",
|
||||
@@ -1335,5 +1354,571 @@
|
||||
"listFilter_repeaters": "Opakovadlá",
|
||||
"listFilter_roomServers": "Servéry miestnosti",
|
||||
"listFilter_unreadOnly": "Nezaregistrované len",
|
||||
"listFilter_newGroup": "Nová skupina"
|
||||
"listFilter_newGroup": "Nová skupina",
|
||||
"@neighbors_errorLoading": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_neighborsSubtitle": "Zobraziť susedné body bez skokov.",
|
||||
"neighbors_requestTimedOut": "Súďia žiadajú o časové ukončenie.",
|
||||
"neighbors_receivedData": "Obdielo dáta suseda",
|
||||
"repeater_neighbors": "Súsezný",
|
||||
"neighbors_errorLoading": "Chyba pri načítaní susedov: {error}",
|
||||
"neighbors_repeatersNeighbors": "Opakovadlá Súsezná",
|
||||
"neighbors_noData": "Nie je dostupná žiadna informácia o susedoch.",
|
||||
"channels_createPrivateChannel": "Vytvorte súkromný kanál",
|
||||
"channels_joinPrivateChannel": "Pripojiť sa k súkromnému kanálu",
|
||||
"channels_joinPrivateChannelDesc": "Ručne zadajte tajný kľúč.",
|
||||
"channels_createPrivateChannelDesc": "Zabezpečené pomocou tajného kľúča.",
|
||||
"channels_joinPublicChannel": "Pripojte sa k verejnému kanálu",
|
||||
"channels_joinPublicChannelDesc": "Któvek sátó na tutó kanalizovát.",
|
||||
"channels_joinHashtagChannel": "Pripojte sa k Hashtag Kanálu",
|
||||
"channels_joinHashtagChannelDesc": "Ktoekolikoľvek sa môže pridať do hashtag kanálov.",
|
||||
"channels_scanQrCode": "Skenujte QR kód",
|
||||
"channels_scanQrCodeComingSoon": "Čoskoro",
|
||||
"channels_enterHashtag": "Zadajte hashtag",
|
||||
"channels_hashtagHint": "napr. #tím",
|
||||
"@neighbors_unknownContact": {
|
||||
"placeholders": {
|
||||
"pubkey": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@neighbors_heardAgo": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"neighbors_heardAgo": "Počuli sme to: {time} dozadu",
|
||||
"neighbors_unknownContact": "Neznáma {pubkey}",
|
||||
"settings_locationGPSEnable": "Aktivovať GPS",
|
||||
"settings_locationGPSEnableSubtitle": "Povolí automatické aktualizovanie polohy pomocou GPS.",
|
||||
"settings_locationIntervalSec": "Interval pre GPS (Sekundy)",
|
||||
"settings_locationIntervalInvalid": "Interval musí byť aspoň 60 sekúnd a menej ako 86400 sekúnd.",
|
||||
"contacts_manageRoom": "Spravovať server miestnosti",
|
||||
"room_management": "Správa servera miestnosti",
|
||||
"@community_joinConfirmation": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_created": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_joined": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_qrInstructions": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_alreadyMemberMessage": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleteConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleted": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_forCommunity": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_create": "Vytvoriť komunitu",
|
||||
"community_title": "Komunita",
|
||||
"community_createDesc": "Vytvorte novú komunitu a zdieľajte cez QR kód.",
|
||||
"community_join": "Pripojiť",
|
||||
"community_joinTitle": "Pripojiť sa k spoločenstvu",
|
||||
"community_joinConfirmation": "Chceš sa pridať do komunity \"{name}\"?",
|
||||
"community_scanQr": "Skontrolujte komunitný QR kód",
|
||||
"community_scanInstructions": "Zamerte kameru na komunitný QR kód.",
|
||||
"community_showQr": "Zobraziť QR kód",
|
||||
"common_ok": "OK\nDobre",
|
||||
"community_publicChannel": "Komunita verejná",
|
||||
"community_hashtagChannel": "Komunitný Hashtag",
|
||||
"community_name": "Komunita",
|
||||
"community_enterName": "Zadajte názov komunity",
|
||||
"community_created": "Komunita \"{name}\" vytvorená",
|
||||
"community_joined": "Pripojená komunita \"{name}\"",
|
||||
"community_qrTitle": "Zdieľť komunitu",
|
||||
"community_qrInstructions": "Skenejte tento QR kód, aby ste sa pripojili k {name}.",
|
||||
"community_hashtagPrivacyHint": "Hashtagové kanály komunity sú prístupné len členom komunity",
|
||||
"community_invalidQrCode": "Neplatná QR kód komunity.",
|
||||
"community_alreadyMember": "Už ste členom.",
|
||||
"community_alreadyMemberMessage": "Vy ste už členom \"{name}\".",
|
||||
"community_addPublicChannel": "Pridať verejný komunikačný kanál",
|
||||
"community_addPublicChannelHint": "Automaticky prida verejný kanál pre túto komunitu.",
|
||||
"community_noCommunities": "Zatiaľ ste sa nepripojili k žiadnej komunite",
|
||||
"community_scanOrCreate": "Skene QR kód alebo vytvor komunitu na začiatok.",
|
||||
"community_manageCommunities": "Spravovať komunity",
|
||||
"community_delete": "Nechajte komunitu",
|
||||
"community_deleteConfirm": "Opustiť \"{name}\"?",
|
||||
"community_deleteChannelsWarning": "Tým sa tiež vymaže {count} kanál/kanálov a ich správy.",
|
||||
"@community_deleteChannelsWarning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_deleted": "Opustená komunita \"{name}\"",
|
||||
"community_addHashtagChannel": "Pridať komunitný hashtag",
|
||||
"community_addHashtagChannelDesc": "Pridajte hashtagový kanál pre túto komunitu.",
|
||||
"community_selectCommunity": "Vyberte komunitu",
|
||||
"community_regularHashtag": "Zvyčajný hashtag",
|
||||
"community_regularHashtagDesc": "Veľký hashtag (ktočokoľvek sa môže pridať)",
|
||||
"community_communityHashtag": "Komunitný Hashtag",
|
||||
"community_communityHashtagDesc": "Špecifické pre členov komunity",
|
||||
"community_forCommunity": "Pre {name}",
|
||||
"@community_regenerateSecretConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretRegenerated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretUpdated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_scanToUpdateSecret": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_secretRegenerated": "Záznam pre \"{name}\" bol regenerovaný tajne",
|
||||
"community_regenerateSecretConfirm": "Znovu vygenerovať tajný kľúč pre \"{name}\"? Všetci členovia budú musieť skanovať nový QR kód, aby mohli nadviazať komunikáciu.",
|
||||
"community_regenerate": "Znovu vygenerovať",
|
||||
"community_regenerateSecret": "Zobraziť nový tajný kód",
|
||||
"community_scanToUpdateSecret": "Skáňte nový QR kód na aktualizáciu tajného hesla pre \"{name}\"",
|
||||
"community_updateSecret": "Aktualizovať tajné heslo",
|
||||
"community_secretUpdated": "Zmena tajnej slova pre \"{name}\"",
|
||||
"@contacts_pathTraceTo": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pathTrace_you": "Vy",
|
||||
"pathTrace_failed": "Sledovanie cesty zlyhalo.",
|
||||
"pathTrace_notAvailable": "Path trace nie je k dispozícii.",
|
||||
"pathTrace_refreshTooltip": "Obnoviť Path Trace.",
|
||||
"contacts_pathTrace": "Sledovanie lúčov",
|
||||
"contacts_ping": "Pingovať",
|
||||
"contacts_repeaterPathTrace": "Sledovanie cesty k opakovaču",
|
||||
"contacts_repeaterPing": "Pingovať opakovač",
|
||||
"contacts_roomPathTrace": "Sledovanie cesty k serveru miestnosti",
|
||||
"contacts_roomPing": "Ping server miestnosti",
|
||||
"contacts_chatTraceRoute": "Sledovať trasu lúča",
|
||||
"contacts_pathTraceTo": "Sledovať trasu k {name}",
|
||||
"contacts_clipboardEmpty": "Schránka je prázdna.",
|
||||
"appSettings_languageUk": "Ukrajinská",
|
||||
"contacts_contactImportFailed": "Kontakt sa nepodarilo importovať.",
|
||||
"contacts_zeroHopAdvert": "Inzerát Zero Hop",
|
||||
"contacts_floodAdvert": "Inzerát povodní",
|
||||
"contacts_copyAdvertToClipboard": "Kopírovať reklamu do schránky",
|
||||
"contacts_invalidAdvertFormat": "Neplatné kontaktné údaje",
|
||||
"appSettings_languageRu": "Ruština",
|
||||
"appSettings_enableMessageTracing": "Povoliť sledovanie správ",
|
||||
"appSettings_enableMessageTracingSubtitle": "Zobraziť podrobné metadáta o smerovaní a časovaní správ",
|
||||
"contacts_addContactFromClipboard": "Pridať kontakt z schránky",
|
||||
"contacts_contactImported": "Kontakt bol importovaný.",
|
||||
"contacts_zeroHopContactAdvertSent": "Poslal kontakt cez inzerát.",
|
||||
"contacts_contactAdvertCopied": "Inzerát bol skopírovaný do schránky.",
|
||||
"contacts_contactAdvertCopyFailed": "Kopírovanie inzerátu do schránky zlyhalo.",
|
||||
"contacts_zeroHopContactAdvertFailed": "Zlyhalo odoslanie kontaktu.",
|
||||
"contacts_ShareContactZeroHop": "Zdieľať kontakt cez inzerát",
|
||||
"contacts_ShareContact": "Kopírovať kontakt do schránky",
|
||||
"notification_activityTitle": "Aktivita MeshCore",
|
||||
"notification_messagesCount": "{count} {count, plural, =1{správa} few{správy} other{správ}}",
|
||||
"notification_channelMessagesCount": "{count} {count, plural, =1{správa kanálu} few{správy kanálu} other{správ kanálu}}",
|
||||
"notification_newNodesCount": "{count} {count, plural, =1{nový uzol} few{nové uzly} other{nových uzlov}}",
|
||||
"notification_newTypeDiscovered": "Nový {contactType} objavený",
|
||||
"notification_receivedNewMessage": "Prijatá nová správa",
|
||||
"settings_gpxExportRepeatersSubtitle": "Exportuje repeater / roomserver s lokalitou do súboru GPX.",
|
||||
"settings_gpxExportContacts": "Export sprievodcov do GPX",
|
||||
"settings_gpxExportSuccess": "Úspešne exportovaný súbor GPX.",
|
||||
"settings_gpxExportNoContacts": "Žiadne kontakty na export.",
|
||||
"settings_gpxExportNotAvailable": "Nie je podporované na vašom zariadení/operáciomnom systéme",
|
||||
"settings_gpxExportRepeatersRoom": "Umiestnenia opakovačov a serverov miestností",
|
||||
"settings_gpxExportError": "Vyskytol sa chyba počas exportu.",
|
||||
"settings_gpxExportAllSubtitle": "Exportuje všetky kontakty s lokalitou do súboru GPX.",
|
||||
"settings_gpxExportContactsSubtitle": "Exportuje sprievodcov s umiestnením do súboru GPX.",
|
||||
"settings_gpxExportRepeaters": "Exportovať repeater / server miestnosti do GPX",
|
||||
"settings_gpxExportAll": "Exportovať všetky kontakty do GPX",
|
||||
"settings_gpxExportAllContacts": "Všetky kontaktné lokality",
|
||||
"settings_gpxExportChat": "Lokácie sprievodcov",
|
||||
"settings_gpxExportShareText": "Mapové údaje exportované z meshcore-open",
|
||||
"settings_gpxExportShareSubject": "meshcore-open export dát GPX mapových údajov",
|
||||
"pathTrace_someHopsNoLocation": "Jedna alebo viac chmeľov chýba lokalita!",
|
||||
"pathTrace_clearTooltip": "Zmazať cestu",
|
||||
"map_tapToAdd": "Kliknite na uzly, aby ste ich pridali k ceste.",
|
||||
"map_removeLast": "Odstrániť posledný",
|
||||
"map_runTrace": "Spustiť trasovaním cesty",
|
||||
"map_pathTraceCancelled": "Zrušenie stopáže cesty bolo zrušené.",
|
||||
"scanner_bluetoothOffMessage": "Prosím, zapnite Bluetooth, aby ste mohli skenovať pre zariadenia.",
|
||||
"scanner_chromeRequired": "Vyžaduje sa prehliadač Chrome",
|
||||
"scanner_chromeRequiredMessage": "Táto webová aplikácia vyžaduje Google Chrome alebo prehliadač založený na Chromium pre podporu Bluetooth.",
|
||||
"scanner_bluetoothOff": "Bluetooth je vypnutý",
|
||||
"scanner_enableBluetooth": "Povolte Bluetooth",
|
||||
"snrIndicator_lastSeen": "Naposledy videný",
|
||||
"snrIndicator_nearByRepeaters": "Miestne opakovače",
|
||||
"chat_ShowAllPaths": "Zobraziť všetky cesty",
|
||||
"settings_clientRepeat": "Opätovné použitie bez elektrickej siete",
|
||||
"settings_clientRepeatFreqWarning": "Použitie off-grid systému vyžaduje frekvencie 433, 869 alebo 918 MHz.",
|
||||
"settings_clientRepeatSubtitle": "Umožnite, aby toto zariadenie opakovávalo siete pre ostatných.",
|
||||
"settings_aboutOpenMeteoAttribution": "Údaje o nadmorskej výške LOS: Open-Meteo (CC BY 4.0)",
|
||||
"appSettings_unitsTitle": "Jednotky",
|
||||
"appSettings_unitsMetric": "Metrické (m / km)",
|
||||
"appSettings_unitsImperial": "Imperiálne (ft / mi)",
|
||||
"map_lineOfSight": "Line of Sight",
|
||||
"map_losScreenTitle": "Line of Sight",
|
||||
"losSelectStartEnd": "Vyberte počiatočný a koncový uzol pre LOS.",
|
||||
"losRunFailed": "Kontrola priamej viditeľnosti zlyhala: {error}",
|
||||
"@losRunFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losClearAllPoints": "Vymazať všetky body",
|
||||
"losRunToViewElevationProfile": "Ak chcete zobraziť výškový profil, spustite LOS",
|
||||
"losMenuTitle": "Menu LOS",
|
||||
"losMenuSubtitle": "Klepnutím na uzly alebo dlhým stlačením mapy získate vlastné body",
|
||||
"losShowDisplayNodes": "Zobraziť uzly zobrazenia",
|
||||
"losCustomPoints": "Vlastné body",
|
||||
"losCustomPointLabel": "Vlastné {index}",
|
||||
"@losCustomPointLabel": {
|
||||
"placeholders": {
|
||||
"index": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losPointA": "Bod A",
|
||||
"losPointB": "Bod B",
|
||||
"losAntennaA": "Anténa A: {value} {unit}",
|
||||
"@losAntennaA": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losAntennaB": "Anténa B: {value} {unit}",
|
||||
"@losAntennaB": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losRun": "Spustite LOS",
|
||||
"losNoElevationData": "Žiadne údaje o nadmorskej výške",
|
||||
"losProfileClear": "{distance} {distanceUnit}, vymazať LOS, min. vôľa {clearance} {heightUnit}",
|
||||
"@losProfileClear": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"clearance": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losProfileBlocked": "{distance} {distanceUnit}, blokovaný {obstruction} {heightUnit}",
|
||||
"@losProfileBlocked": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"obstruction": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losStatusChecking": "LOS: kontrolujem...",
|
||||
"losStatusNoData": "LOS: žiadne údaje",
|
||||
"losStatusSummary": "LOS: {clear}/{total} vymazané, {blocked} blokované, {unknown} neznáme",
|
||||
"@losStatusSummary": {
|
||||
"placeholders": {
|
||||
"clear": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "int"
|
||||
},
|
||||
"unknown": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losErrorElevationUnavailable": "Údaje o nadmorskej výške nie sú k dispozícii pre jednu alebo viacero vzoriek.",
|
||||
"losErrorInvalidInput": "Neplatné body/údaje o nadmorskej výške pre výpočet LOS.",
|
||||
"losRenameCustomPoint": "Premenovať vlastný bod",
|
||||
"losPointName": "Názov bodu",
|
||||
"losShowPanelTooltip": "Zobraziť panel LOS",
|
||||
"losHidePanelTooltip": "Skryť panel LOS",
|
||||
"losElevationAttribution": "Údaje o nadmorskej výške: Open-Meteo (CC BY 4.0)",
|
||||
"losLegendRadioHorizon": "Rádiový horizont",
|
||||
"losLegendLosBeam": "Priama viditeľnosť",
|
||||
"losLegendTerrain": "Terén",
|
||||
"losFrequencyLabel": "Frekvencia",
|
||||
"losFrequencyInfoTooltip": "Zobraziť podrobnosti výpočtu",
|
||||
"losFrequencyDialogTitle": "Výpočet rádiového horizontu",
|
||||
"losFrequencyDialogDescription": "Počnúc od k={baselineK} pri {baselineFreq} MHz výpočet upraví k-faktor pre aktuálne pásmo {frequencyMHz} MHz, ktorý definuje zakrivený strop rádiového horizontu.",
|
||||
"@losFrequencyDialogDescription": {
|
||||
"description": "Explain how the calculation uses the baseline frequency and derived k-factor.",
|
||||
"placeholders": {
|
||||
"baselineK": {
|
||||
"type": "double"
|
||||
},
|
||||
"baselineFreq": {
|
||||
"type": "double"
|
||||
},
|
||||
"frequencyMHz": {
|
||||
"type": "double"
|
||||
},
|
||||
"kFactor": {
|
||||
"type": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
"listFilter_removeFromFavorites": "Odstrániť z označení",
|
||||
"listFilter_addToFavorites": "Pridaj do obľúbených",
|
||||
"listFilter_favorites": "Obľúbené",
|
||||
"@contacts_searchFavorites": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchUsers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRepeaters": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRoomServers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contacts_searchRoomServers": "Hľadaj {number}{str} serverov miestností...",
|
||||
"contacts_searchFavorites": "Hľadať {number}{str} obľúbené...",
|
||||
"contacts_searchRepeaters": "Hľadať {number}{str} opakovače...",
|
||||
"contacts_searchUsers": "Hľadať {number}{str} používateľov...",
|
||||
"contacts_searchContactsNoNumber": "Hľadať kontakty...",
|
||||
"contacts_unread": "Neprečítané",
|
||||
"settings_contactSettingsSubtitle": "Nastavenia pre pridávanie kontaktov.",
|
||||
"contactsSettings_autoAddUsersTitle": "Automaticky pridávať užívateľov",
|
||||
"contactsSettings_autoAddUsersSubtitle": "Povoliť spoločníkovi automaticky pridávať objavených užívateľov.",
|
||||
"contactsSettings_autoAddRepeatersTitle": "Automaticky pridávať opakovače",
|
||||
"contactsSettings_autoAddRoomServersTitle": "Automaticky pridávať server miestnosti",
|
||||
"contactsSettings_autoAddRoomServersSubtitle": "Povoliť spoločníkovi automaticky pridať objavené serverové miestnosti.",
|
||||
"contactsSettings_autoAddTitle": "Automatické zisťovanie",
|
||||
"contactsSettings_title": "Nastavenia kontaktov",
|
||||
"contactsSettings_otherTitle": "Ďalšie nastavenia súvisiace s kontaktami",
|
||||
"settings_contactSettings": "Nastavenia kontaktov",
|
||||
"contactsSettings_autoAddSensorsTitle": "Automaticky pridávať senzory",
|
||||
"discoveredContacts_noMatching": "Žiadne zhodné kontakty",
|
||||
"discoveredContacts_searchHint": "Vyhľadať objavené kontakty",
|
||||
"contactsSettings_autoAddRepeatersSubtitle": "Povoliť spoločníkovi automaticky pridávať objavené repeater.",
|
||||
"discoveredContacts_contactAdded": "Kontakt bol pridaný",
|
||||
"discoveredContacts_copyContact": "Kopírovať kontakt do schránky",
|
||||
"discoveredContacts_deleteContact": "Zmazať kontakt",
|
||||
"contactsSettings_autoAddSensorsSubtitle": "Povoliť spoločníkovi automaticky pridávať objavené senzory.",
|
||||
"discoveredContacts_Title": "Objavené kontakty",
|
||||
"contactsSettings_overwriteOldestTitle": "Prepísať najstaršie",
|
||||
"discoveredContacts_addContact": "Pridať kontakt",
|
||||
"contactsSettings_overwriteOldestSubtitle": "Keď je zoznam kontaktov plný, bude nahradený najstarší neoznačený kontakt.",
|
||||
"discoveredContacts_deleteContactAll": "Zmazať všetky objavené kontakty",
|
||||
"common_deleteAll": "Zmazať všetko",
|
||||
"discoveredContacts_deleteContactAllContent": "Ste si istí, že chcete zmazať všetky objavené kontakty?",
|
||||
"map_showGuessedLocations": "Zobraziť umiestnenia odhadnutých uzlov",
|
||||
"map_guessedLocation": "Odhadnutá lokalita",
|
||||
"usbScreenTitle": "Pripojte cez USB",
|
||||
"usbScreenSubtitle": "Vyberte detekovaný sériový zariadenie a pripojte ho priamo k vašej MeshCore uzlu.",
|
||||
"connectionChoiceUsbLabel": "USB",
|
||||
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||
"usbScreenStatus": "Vyberte USB zariadenie",
|
||||
"usbScreenNote": "USB sériová komunikácia je aktívna na podporovaných zariadeniach s Androidom a na desktopových platformách.",
|
||||
"usbScreenEmptyState": "Nenašli sa žiadne USB zariadenia. Pripojte jedno a obnovte.",
|
||||
"usbErrorPermissionDenied": "Žiadosť o prístup cez USB bola zamietnutá.",
|
||||
"usbErrorDeviceMissing": "Vybrané USB zariadenie už nie je dostupné.",
|
||||
"usbErrorInvalidPort": "Vyberte platné USB zariadenie.",
|
||||
"usbErrorBusy": "Ďalšia požiadavka na pripojenie cez USB je aktuálne v procese.",
|
||||
"usbErrorNotConnected": "Nie je pripojené žiadne USB zariadenie.",
|
||||
"usbErrorOpenFailed": "Nepodarilo sa otvoriť vybrané USB zariadenie.",
|
||||
"usbErrorConnectFailed": "Nepodarilo sa sa sa pripojiť k vybranému USB zariadeniu.",
|
||||
"usbErrorUnsupported": "Podpora USB sériového rozhrania nie je na tejto platforme dostupná.",
|
||||
"usbErrorAlreadyActive": "Pripojenie cez USB je už aktivované.",
|
||||
"usbErrorNoDeviceSelected": "Nebolo vybrané žiadne USB zariadenie.",
|
||||
"usbErrorPortClosed": "Pripojenie cez USB nie je aktivované.",
|
||||
"usbFallbackDeviceName": "Webový sériový zariadenie",
|
||||
"@usbConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"usbStatus_searching": "Hľadanie USB zariadení...",
|
||||
"usbConnectionFailed": "Neúspešné pripojenie cez USB: {error}",
|
||||
"usbStatus_notConnected": "Vyberte USB zariadenie",
|
||||
"usbStatus_connecting": "Pripojenie k USB zariadeniu...",
|
||||
"usbErrorConnectTimedOut": "Pripojenie nebolo úspešné. Uistite sa, že zariadenie má nainštalovaný firmware USB Companion.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpHostLabel": "IP adresa",
|
||||
"tcpScreenTitle": "Spojte sa pomocou protokolu TCP",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpPortLabel": "Port",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Zadajte cieľovú adresu a pripojte sa.",
|
||||
"tcpStatus_connectingTo": "Pripojenie k {endpoint}...",
|
||||
"tcpErrorHostRequired": "Je potrebné zadať IP adresu.",
|
||||
"tcpErrorPortInvalid": "Číslo portu musí byť medzi 1 a 65535.",
|
||||
"tcpErrorUnsupported": "Prevoz prostredníctvom protokolu TCP nie je na tejto platforme podporovaný.",
|
||||
"tcpErrorTimedOut": "Pripojenie TCP vypršalo.",
|
||||
"tcpConnectionFailed": "Neúspešné vytvorenie TCP spojenia: {error}",
|
||||
"map_showDiscoveryContacts": "Zobraziť kontakty objavov",
|
||||
"map_setAsMyLocation": "Nastavte ako moju polohu",
|
||||
"settings_privacy": "Nastavenia súkromia",
|
||||
"settings_privacySubtitle": "Ovládni, aké informácie sa zdieľajú.",
|
||||
"settings_telemetryLocationMode": "Režim umiestnenia telemetrie",
|
||||
"settings_telemetryBaseMode": "Základný režim telemetrie",
|
||||
"settings_advertLocation": "Umiestnenie inzerátu",
|
||||
"settings_telemetryEnvironmentMode": "Režim prostredia telemetrie",
|
||||
"settings_advertLocationSubtitle": "Zahrnúť polohu do inzerátu",
|
||||
"settings_allowAll": "Povoliť všetko",
|
||||
"settings_privacySettingsDescription": "Vyberte, ktoré informácie váš zariadenie zdieľa s ostatnými.",
|
||||
"settings_denyAll": "Zamietnuť všetko",
|
||||
"settings_allowByContact": "Povoliť podľa kontaktových vlajok",
|
||||
"contact_info": "Kontaktné informácie",
|
||||
"contact_settings": "Nastavenia kontaktov",
|
||||
"contact_teleBaseSubtitle": "Povoliť zdieľanie úrovne batérie a základnej telemetrie",
|
||||
"contact_teleLoc": "Lokácia telemetrie",
|
||||
"contact_teleLocSubtitle": "Povoliť zdieľanie údajov o lokalite",
|
||||
"contact_teleEnv": "Prostredie telemetrie",
|
||||
"contact_telemetry": "Telemetria",
|
||||
"contact_clearChat": "Vymazať chat",
|
||||
"contact_lastSeen": "Naposledy videný",
|
||||
"contact_teleBase": "Báza telemetrie",
|
||||
"contact_teleEnvSubtitle": "Povoliť zdieľanie údajov senzorov prostredia",
|
||||
"@settings_multiAck": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings_telemetryModeUpdated": "Režim telemetrie bol aktualizovaný",
|
||||
"settings_multiAck": "Viaceré ACK: {value}"
|
||||
}
|
||||
|
||||
+724
-139
File diff suppressed because it is too large
Load Diff
+591
-6
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"channels_channelDeleteFailed": "Det gick inte att ta bort kanalen \"{name}\"",
|
||||
"@channels_channelDeleteFailed": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@@locale": "sv",
|
||||
"appTitle": "MeshCore Open",
|
||||
"nav_contacts": "Kontakter",
|
||||
@@ -131,9 +139,6 @@
|
||||
"settings_infoContactsCount": "Kontakterantal",
|
||||
"settings_infoChannelCount": "Kanalantal",
|
||||
"settings_presets": "Fördefinierade inställningar",
|
||||
"settings_preset915Mhz": "915 MHz",
|
||||
"settings_preset868Mhz": "868 MHz",
|
||||
"settings_preset433Mhz": "433 MHz",
|
||||
"settings_frequency": "Frekvens (MHz)",
|
||||
"settings_frequencyHelper": "300,0 - 2500,0",
|
||||
"settings_frequencyInvalid": "Ogiltig frekvens (300-2500 MHz)",
|
||||
@@ -143,8 +148,6 @@
|
||||
"settings_txPower": "TX-effekt (dBm)",
|
||||
"settings_txPowerHelper": "0 - 22",
|
||||
"settings_txPowerInvalid": "Ogiltig TX-effekt (0-22 dBm)",
|
||||
"settings_longRange": "Lång räckvidd",
|
||||
"settings_fastSpeed": "Snabb hastighet",
|
||||
"settings_error": "Fel: {message}",
|
||||
"@settings_error": {
|
||||
"placeholders": {
|
||||
@@ -282,6 +285,7 @@
|
||||
"contacts_newGroup": "Ny grupp",
|
||||
"contacts_groupName": "Gruppnamn",
|
||||
"contacts_groupNameRequired": "Gruppnamnet är obligatoriskt",
|
||||
"contacts_groupNameReserved": "Detta gruppnamn är reserverat",
|
||||
"contacts_groupAlreadyExists": "Gruppen \"{name}\" finns redan.",
|
||||
"@contacts_groupAlreadyExists": {
|
||||
"placeholders": {
|
||||
@@ -339,6 +343,8 @@
|
||||
"channels_publicChannel": "Allmänt kanal",
|
||||
"channels_privateChannel": "Privat kanal",
|
||||
"channels_editChannel": "Redigera kanal",
|
||||
"channels_muteChannel": "Tysta kanal",
|
||||
"channels_unmuteChannel": "Slå på ljud för kanal",
|
||||
"channels_deleteChannel": "Ta bort kanal",
|
||||
"channels_deleteChannelConfirm": "Radera \"{name}\"? Detta kan inte ångras.",
|
||||
"@channels_deleteChannelConfirm": {
|
||||
@@ -604,6 +610,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_openLink": "Öppna länk?",
|
||||
"chat_openLinkConfirmation": "Vill du öppna den här länken i din webbläsare?",
|
||||
"chat_open": "Öppna",
|
||||
"chat_couldNotOpenLink": "Kunde inte öppna länken: {url}",
|
||||
"@chat_couldNotOpenLink": {
|
||||
"placeholders": {
|
||||
"url": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat_invalidLink": "Ogiltigt länkformat",
|
||||
"map_title": "Nodkarta",
|
||||
"map_noNodesWithLocation": "Inga noder med platsinformation",
|
||||
"map_nodesNeedGps": "Noder måste dela sina GPS-koordinater\nför att visas på kartan",
|
||||
@@ -821,6 +839,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"login_failedMessage": "Inloggning misslyckades. Antingen är lösenordet fel eller så går det inte att nå repeatern.",
|
||||
"common_reload": "Ladda om",
|
||||
"common_clear": "Rensa",
|
||||
"path_currentPath": "Nuvarande sökväg: {path}",
|
||||
@@ -1335,5 +1354,571 @@
|
||||
"listFilter_repeaters": "Upprepare",
|
||||
"listFilter_roomServers": "Rumservrar",
|
||||
"listFilter_unreadOnly": "Endast oinlästa",
|
||||
"listFilter_newGroup": "Ny grupp"
|
||||
"listFilter_newGroup": "Ny grupp",
|
||||
"@neighbors_errorLoading": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_neighbors": "Grannar",
|
||||
"repeater_neighborsSubtitle": "Visa noll hoppgrannar.",
|
||||
"neighbors_receivedData": "Mottagna grannars data",
|
||||
"neighbors_requestTimedOut": "Grannar begär tidsinställd utskick.",
|
||||
"neighbors_errorLoading": "Fel vid inläsning av grannar: {error}",
|
||||
"neighbors_repeatersNeighbors": "Upprepar grannar",
|
||||
"neighbors_noData": "Inga grannuppgifter finns tillgängliga.",
|
||||
"channels_createPrivateChannel": "Skapa en privat kanal",
|
||||
"channels_joinPrivateChannel": "Gå med i en Privat Kanal",
|
||||
"channels_joinPrivateChannelDesc": "Ange en hemlig nyckel manuellt.",
|
||||
"channels_createPrivateChannelDesc": "Skyddat med en hemlig nyckel.",
|
||||
"channels_joinPublicChannel": "Gå med i den Offentliga Kanalen",
|
||||
"channels_joinPublicChannelDesc": "Vem som helst kan gå med i denna kanal.",
|
||||
"channels_joinHashtagChannel": "Gå med i en Hashtagkanal",
|
||||
"channels_joinHashtagChannelDesc": "Väldigt enkelt att gå med i hashtag-kanaler.",
|
||||
"channels_scanQrCode": "Skanna en QR-kod",
|
||||
"channels_scanQrCodeComingSoon": "Kommer snart",
|
||||
"channels_enterHashtag": "Ange hashtag",
|
||||
"channels_hashtagHint": "t.ex. #team",
|
||||
"@neighbors_unknownContact": {
|
||||
"placeholders": {
|
||||
"pubkey": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@neighbors_heardAgo": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"neighbors_heardAgo": "Hördes: {time} sedan",
|
||||
"neighbors_unknownContact": "Okänd {pubkey}",
|
||||
"settings_locationGPSEnable": "Aktivera GPS",
|
||||
"settings_locationGPSEnableSubtitle": "Aktivera automatiska uppdateringar av platsen med hjälp av GPS.",
|
||||
"settings_locationIntervalSec": "Interval för GPS (Sekunder)",
|
||||
"settings_locationIntervalInvalid": "Intervalet måste vara minst 60 sekunder och mindre än 86400 sekunder.",
|
||||
"contacts_manageRoom": "Hantera Rumserver",
|
||||
"room_management": "Rumserverhantering",
|
||||
"@community_joinConfirmation": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_created": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_joined": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_qrInstructions": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_alreadyMemberMessage": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleteConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_deleted": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_forCommunity": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_create": "Skapa Gemenskap",
|
||||
"community_createDesc": "Skapa en ny gemenskap och dela via QR-kod.",
|
||||
"common_ok": "Okej",
|
||||
"community_title": "Gemenskap",
|
||||
"community_join": "Gå med",
|
||||
"community_joinTitle": "Gå med i gemenskapen",
|
||||
"community_joinConfirmation": "Vill du gå med i communityn \"{name}\"?",
|
||||
"community_scanQr": "Skanna Gemenskapens QR",
|
||||
"community_scanInstructions": "Rikta kameran mot en QR-kod i communityn",
|
||||
"community_showQr": "Visa QR-kod",
|
||||
"community_publicChannel": "Föreningens Offentliga",
|
||||
"community_name": "Gemenskapens namn",
|
||||
"community_enterName": "Ange communities namn",
|
||||
"community_created": "Community \"{name}\" har skapats",
|
||||
"community_joined": "Medlem i communityn \"{name}\"",
|
||||
"community_qrTitle": "Dela Gemenskap",
|
||||
"community_qrInstructions": "Skanna denna QR-kod för att gå med i \"{name}\"",
|
||||
"community_hashtagPrivacyHint": "Community-hashtagkanaler kan endast nås av medlemmar i communityn",
|
||||
"community_hashtagChannel": "Community Hashtag",
|
||||
"community_invalidQrCode": "Ogiltig community QR-kod",
|
||||
"community_alreadyMember": "Är redan medlem",
|
||||
"community_alreadyMemberMessage": "Du är redan medlem av \"{name}\".",
|
||||
"community_addPublicChannel": "Lägg till Gemenskapskanal (Offentlig)",
|
||||
"community_addPublicChannelHint": "Lägg automatiskt till den offentliga kanalen för denna community",
|
||||
"community_noCommunities": "Inga gemenskaper har anslutats ännu",
|
||||
"community_scanOrCreate": "Skanna en QR-kod eller skapa en community för att komma igång",
|
||||
"community_manageCommunities": "Hantera Gemenskaper",
|
||||
"community_delete": "Lämna Gemenskap",
|
||||
"community_deleteConfirm": "Lämna \"{name}\"?",
|
||||
"community_deleteChannelsWarning": "Detta kommer också att radera {count} kanal/kanaler och deras meddelanden.",
|
||||
"@community_deleteChannelsWarning": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_deleted": "Lämnade community \"{name}\"",
|
||||
"community_addHashtagChannel": "Lägg till Gemenskapens Hashtag",
|
||||
"community_addHashtagChannelDesc": "Lägg till en hashtag-kanal för denna community",
|
||||
"community_selectCommunity": "Välj Gemenskap",
|
||||
"community_regularHashtag": "Vanlig Hash Tag",
|
||||
"community_regularHashtagDesc": "Offentlig hashtag (alla kan gå med)",
|
||||
"community_communityHashtagDesc": "Endast för medlemmar",
|
||||
"community_forCommunity": "För {name}",
|
||||
"community_communityHashtag": "Community Hashtag",
|
||||
"@community_regenerateSecretConfirm": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretRegenerated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_secretUpdated": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@community_scanToUpdateSecret": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"community_regenerate": "Regenerera",
|
||||
"community_regenerateSecretConfirm": "Regenerera den hemliga nyckeln för \"{name}\"? Alla medlemmar måste scanna den nya QR-koden för att fortsätta kommunicera.",
|
||||
"community_secretRegenerated": "Lösenord återskapad för \"{name}\"",
|
||||
"community_regenerateSecret": "Regenerera hemlig kod",
|
||||
"community_scanToUpdateSecret": "Skanna den nya QR-koden för att uppdatera hemligheten för \"{name}\"",
|
||||
"community_secretUpdated": "Hemlighet uppdaterad för \"{name}\"",
|
||||
"community_updateSecret": "Uppdatera hemlighet",
|
||||
"@contacts_pathTraceTo": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pathTrace_you": "Du",
|
||||
"pathTrace_failed": "Sökvägsföljning misslyckades.",
|
||||
"pathTrace_notAvailable": "Path trace ej tillgänglig.",
|
||||
"pathTrace_refreshTooltip": "Uppdatera Path Trace",
|
||||
"contacts_pathTrace": "Path Trace",
|
||||
"contacts_ping": "Ping",
|
||||
"contacts_repeaterPathTrace": "Vägspårning till repeater",
|
||||
"contacts_repeaterPing": "Ping-repeater",
|
||||
"contacts_roomPathTrace": "Vägspårning till rumserver",
|
||||
"contacts_roomPing": "Ping rumsserver",
|
||||
"contacts_chatTraceRoute": "Spåra rutt",
|
||||
"contacts_pathTraceTo": "Spåra rutt till {name}",
|
||||
"contacts_clipboardEmpty": "Urklipp är tomt.",
|
||||
"appSettings_languageRu": "Ryska",
|
||||
"contacts_contactImportFailed": "Kontakt kunde inte importeras.",
|
||||
"contacts_zeroHopAdvert": "Reklam med nollhopp",
|
||||
"contacts_floodAdvert": "Översvämningsannons",
|
||||
"contacts_copyAdvertToClipboard": "Kopiera annons till urklipp",
|
||||
"contacts_invalidAdvertFormat": "Ogiltiga kontaktuppgifter",
|
||||
"appSettings_languageUk": "Ukrainska",
|
||||
"appSettings_enableMessageTracing": "Aktivera meddelandespårning",
|
||||
"appSettings_enableMessageTracingSubtitle": "Visa detaljerade metadata om dirigering och tidsinställningar för meddelanden",
|
||||
"contacts_addContactFromClipboard": "Lägg till kontakt från urklipp",
|
||||
"contacts_contactImported": "Kontakt har importerats.",
|
||||
"contacts_zeroHopContactAdvertSent": "Skickat kontakt via annons.",
|
||||
"contacts_contactAdvertCopied": "Annons kopierad till Urklipp.",
|
||||
"contacts_contactAdvertCopyFailed": "Kopiering av annons till Urklipp misslyckades.",
|
||||
"contacts_ShareContact": "Kopiera kontakt till Urklipp",
|
||||
"contacts_zeroHopContactAdvertFailed": "Misslyckades med att skicka kontakt.",
|
||||
"contacts_ShareContactZeroHop": "Dela kontakt via annons",
|
||||
"notification_activityTitle": "MeshCore Aktivitet",
|
||||
"notification_messagesCount": "{count} {count, plural, =1{meddelande} other{meddelanden}}",
|
||||
"notification_channelMessagesCount": "{count} {count, plural, =1{kanalmeddelande} other{kanalmeddelanden}}",
|
||||
"notification_newNodesCount": "{count} {count, plural, =1{ny nod} other{nya noder}}",
|
||||
"notification_newTypeDiscovered": "Ny {contactType} upptäckt",
|
||||
"notification_receivedNewMessage": "Nytt meddelande mottaget",
|
||||
"settings_gpxExportAll": "Exportera alla kontakter till GPX",
|
||||
"settings_gpxExportRepeatersSubtitle": "Exporterar repeater / roomserver med plats till GPX-fil.",
|
||||
"settings_gpxExportSuccess": "Har exporterat GPX-fil med framgång",
|
||||
"settings_gpxExportNoContacts": "Inga kontakter att exportera.",
|
||||
"settings_gpxExportNotAvailable": "Stöds inte på din enhet/operativsystem",
|
||||
"settings_gpxExportRepeatersRoom": "Repeater- och rumsserverplatser",
|
||||
"settings_gpxExportRepeaters": "Exportera repeater / rumsservrar till GPX",
|
||||
"settings_gpxExportAllSubtitle": "Exporterar alla kontakter med en plats till GPX-fil.",
|
||||
"settings_gpxExportContacts": "Exportera följeslagare till GPX",
|
||||
"settings_gpxExportContactsSubtitle": "Exporterar följeslagare med en plats till GPX-fil.",
|
||||
"settings_gpxExportChat": "Medhjälparplatser",
|
||||
"settings_gpxExportError": "Det uppstod ett fel när data exporterades.",
|
||||
"settings_gpxExportAllContacts": "Alla kontakters platser",
|
||||
"settings_gpxExportShareSubject": "meshcore-open export av GPX-kartdata",
|
||||
"settings_gpxExportShareText": "Kartdata exporterad från meshcore-open",
|
||||
"pathTrace_someHopsNoLocation": "En eller flera av humlen saknar en plats!",
|
||||
"pathTrace_clearTooltip": "Rensa väg",
|
||||
"map_pathTraceCancelled": "Sökvägsspårning avbruten.",
|
||||
"map_runTrace": "Kör spårsökning",
|
||||
"map_tapToAdd": "Tryck på noder för att lägga till dem i banan.",
|
||||
"map_removeLast": "Ta bort sista",
|
||||
"scanner_enableBluetooth": "Aktivera Bluetooth",
|
||||
"scanner_bluetoothOffMessage": "Vänligen aktivera Bluetooth för att söka efter enheter.",
|
||||
"scanner_chromeRequired": "Chrome-webbläsare krävs",
|
||||
"scanner_chromeRequiredMessage": "Denna webbapplikation kräver Google Chrome oder en Chromium-baserader webbläsare för Bluetooth-stöd.",
|
||||
"scanner_bluetoothOff": "Bluetooth är avstängt",
|
||||
"snrIndicator_lastSeen": "Senast sedd",
|
||||
"snrIndicator_nearByRepeaters": "Närliggande uppreparstationer",
|
||||
"chat_ShowAllPaths": "Visa alla vägar",
|
||||
"settings_clientRepeatSubtitle": "Låt enheten repetera nätpaket för andra användare.",
|
||||
"settings_clientRepeat": "Upprepa utan elnät",
|
||||
"settings_clientRepeatFreqWarning": "För att kunna kommunicera utanför elnätet krävs frekvenserna 433, 869 eller 918 MHz.",
|
||||
"settings_aboutOpenMeteoAttribution": "LOS-höjddata: Open-Meteo (CC BY 4.0)",
|
||||
"appSettings_unitsTitle": "Enheter",
|
||||
"appSettings_unitsMetric": "Metriskt (m/km)",
|
||||
"appSettings_unitsImperial": "Imperialt (ft / mi)",
|
||||
"map_lineOfSight": "Synlinje",
|
||||
"map_losScreenTitle": "Synlinje",
|
||||
"losSelectStartEnd": "Välj start- och slutnoder för LOS.",
|
||||
"losRunFailed": "Synlinjekontroll misslyckades: {error}",
|
||||
"@losRunFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losClearAllPoints": "Rensa alla punkter",
|
||||
"losRunToViewElevationProfile": "Kör LOS för att se höjdprofil",
|
||||
"losMenuTitle": "LOS-menyn",
|
||||
"losMenuSubtitle": "Tryck på noder eller tryck länge på kartan för anpassade punkter",
|
||||
"losShowDisplayNodes": "Visa displaynoder",
|
||||
"losCustomPoints": "Anpassade poäng",
|
||||
"losCustomPointLabel": "Anpassad {index}",
|
||||
"@losCustomPointLabel": {
|
||||
"placeholders": {
|
||||
"index": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losPointA": "Punkt A",
|
||||
"losPointB": "Punkt B",
|
||||
"losAntennaA": "Antenn A: {value} {unit}",
|
||||
"@losAntennaA": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losAntennaB": "Antenn B: {value} {unit}",
|
||||
"@losAntennaB": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
},
|
||||
"unit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losRun": "Kör LOS",
|
||||
"losNoElevationData": "Inga höjddata",
|
||||
"losProfileClear": "{distance} {distanceUnit}, rensa LOS, min clearance {clearance} {heightUnit}",
|
||||
"@losProfileClear": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"clearance": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losProfileBlocked": "{distance} {distanceUnit}, blockerad av {obstruction} {heightUnit}",
|
||||
"@losProfileBlocked": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"obstruction": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losStatusChecking": "LOS: kollar...",
|
||||
"losStatusNoData": "LOS: inga data",
|
||||
"losStatusSummary": "LOS: {clear}/{total} rensa, {blocked} blockerad, {unknown} okänd",
|
||||
"@losStatusSummary": {
|
||||
"placeholders": {
|
||||
"clear": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "int"
|
||||
},
|
||||
"unknown": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losErrorElevationUnavailable": "Höjddata är inte tillgänglig för ett eller flera prover.",
|
||||
"losErrorInvalidInput": "Ogiltiga poäng/höjddata för LOS-beräkning.",
|
||||
"losRenameCustomPoint": "Byt namn på anpassad punkt",
|
||||
"losPointName": "Punktnamn",
|
||||
"losShowPanelTooltip": "Visa LOS-panelen",
|
||||
"losHidePanelTooltip": "Dölj LOS-panelen",
|
||||
"losElevationAttribution": "Höjddata: Open-Meteo (CC BY 4.0)",
|
||||
"losLegendRadioHorizon": "Radiohorisont",
|
||||
"losLegendLosBeam": "Siktlinje",
|
||||
"losLegendTerrain": "Terräng",
|
||||
"losFrequencyLabel": "Frekvens",
|
||||
"losFrequencyInfoTooltip": "Visa detaljer om beräkningen",
|
||||
"losFrequencyDialogTitle": "Beräkning av radiohorisonten",
|
||||
"losFrequencyDialogDescription": "Med start från k={baselineK} vid {baselineFreq} MHz, justerar beräkningen k-faktorn för det aktuella {frequencyMHz} MHz-bandet, som definierar den böjda radiohorisonten.",
|
||||
"@losFrequencyDialogDescription": {
|
||||
"description": "Explain how the calculation uses the baseline frequency and derived k-factor.",
|
||||
"placeholders": {
|
||||
"baselineK": {
|
||||
"type": "double"
|
||||
},
|
||||
"baselineFreq": {
|
||||
"type": "double"
|
||||
},
|
||||
"frequencyMHz": {
|
||||
"type": "double"
|
||||
},
|
||||
"kFactor": {
|
||||
"type": "double"
|
||||
}
|
||||
}
|
||||
},
|
||||
"listFilter_removeFromFavorites": "Ta bort från favoriter",
|
||||
"listFilter_addToFavorites": "Lägg till i favoriter",
|
||||
"listFilter_favorites": "Favoriter",
|
||||
"@contacts_searchFavorites": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchUsers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRepeaters": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@contacts_searchRoomServers": {
|
||||
"placeholders": {
|
||||
"number": {
|
||||
"type": "int"
|
||||
},
|
||||
"str": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"contacts_unread": "Oläst",
|
||||
"contacts_searchContactsNoNumber": "Sök kontakter...",
|
||||
"contacts_searchRepeaters": "Sök {number}{str} upprepningsenheter...",
|
||||
"contacts_searchFavorites": "Sök {number}{str} Favoriter...",
|
||||
"contacts_searchUsers": "Sök {number}{str} användare...",
|
||||
"contacts_searchRoomServers": "Sök {number}{str} Room-servrar...",
|
||||
"settings_contactSettingsSubtitle": "Inställningar för hur kontakter läggs till.",
|
||||
"settings_contactSettings": "Kontaktinställningar",
|
||||
"contactsSettings_autoAddTitle": "Automatisk upptäckt",
|
||||
"contactsSettings_otherTitle": "Andra inställningar relaterade till kontakt",
|
||||
"contactsSettings_autoAddUsersSubtitle": "Tillåt kompanjonen att automatiskt lägga till upptäckta användare",
|
||||
"contactsSettings_autoAddRepeatersTitle": "Lägg till upprepande enheter automatiskt",
|
||||
"contactsSettings_autoAddRoomServersSubtitle": "Tillåt kompanjonen att automatiskt lägga till upptäckta rumsservrar.",
|
||||
"contactsSettings_autoAddSensorsTitle": "Lägg till sensorer automatiskt",
|
||||
"contactsSettings_autoAddUsersTitle": "Lägg till användare automatiskt",
|
||||
"contactsSettings_title": "Kontaktinställningar",
|
||||
"contactsSettings_autoAddSensorsSubtitle": "Tillåt kompanjonen att automatiskt lägga till upptäckta sensorer.",
|
||||
"contactsSettings_overwriteOldestTitle": "Skriv över äldst",
|
||||
"contactsSettings_autoAddRepeatersSubtitle": "Tillåt kompanjonen att automatiskt lägga till upptäckta repeater.",
|
||||
"contactsSettings_autoAddRoomServersTitle": "Lägg automatiskt till rumsservrar",
|
||||
"discoveredContacts_noMatching": "Inga matchande kontakter",
|
||||
"discoveredContacts_searchHint": "Sök uppfunna kontakter",
|
||||
"discoveredContacts_deleteContact": "Ta bort kontakt",
|
||||
"discoveredContacts_Title": "Upptäckta kontakter",
|
||||
"discoveredContacts_contactAdded": "Kontakt tillagd",
|
||||
"discoveredContacts_addContact": "Lägg till kontakt",
|
||||
"discoveredContacts_copyContact": "Kopiera kontakt till urklipp",
|
||||
"contactsSettings_overwriteOldestSubtitle": "När kontaktlistan är full ersätts den äldsta icke-favoriterade kontakten.",
|
||||
"common_deleteAll": "Ta bort alla",
|
||||
"discoveredContacts_deleteContactAllContent": "Är du säker på att du vill ta bort alla upptäckta kontakter?",
|
||||
"discoveredContacts_deleteContactAll": "Ta bort alla upptäckta kontakter",
|
||||
"map_guessedLocation": "Gissad plats",
|
||||
"map_showGuessedLocations": "Visa upp de antagna nodernas placeringar",
|
||||
"connectionChoiceBluetoothLabel": "Bluetooth",
|
||||
"connectionChoiceUsbLabel": "USB",
|
||||
"usbScreenSubtitle": "Välj en detekterad seriell enhet och anslut direkt till din MeshCore-nod.",
|
||||
"usbScreenTitle": "Anslut via USB",
|
||||
"usbScreenStatus": "Välj en USB-enhet",
|
||||
"usbScreenNote": "USB-seriell kommunikation är aktiv på stödda Android-enheter och på skrivbordsplattformar.",
|
||||
"usbScreenEmptyState": "Inga USB-enheter hittades. Anslut en och uppdatera.",
|
||||
"usbErrorPermissionDenied": "Tillgången via USB nekas.",
|
||||
"usbErrorDeviceMissing": "Den valda USB-enheten är inte längre tillgänglig.",
|
||||
"usbErrorInvalidPort": "Välj en giltig USB-enhet.",
|
||||
"usbErrorBusy": "En annan förfrågan om USB-anslutning är redan pågående.",
|
||||
"usbErrorNotConnected": "Ingen USB-enhet är ansluten.",
|
||||
"usbErrorOpenFailed": "Misslyckades med att öppna det valda USB-enheten.",
|
||||
"usbErrorConnectFailed": "Kunde inte ansluta till det valda USB-enheten.",
|
||||
"usbErrorUnsupported": "USB-seriell kommunikation stöds inte på denna plattform.",
|
||||
"usbErrorAlreadyActive": "En USB-anslutning är redan aktiv.",
|
||||
"usbErrorNoDeviceSelected": "Ingen USB-enhet valdes.",
|
||||
"usbErrorPortClosed": "USB-anslutningen är inte aktiv.",
|
||||
"usbFallbackDeviceName": "Web-serieenhet",
|
||||
"@usbConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"usbStatus_connecting": "Anslutning till USB-enhet...",
|
||||
"usbStatus_notConnected": "Välj en USB-enhet",
|
||||
"usbConnectionFailed": "Fel vid USB-anslutning: {error}",
|
||||
"usbStatus_searching": "Söker efter USB-enheter...",
|
||||
"usbErrorConnectTimedOut": "Anslutningen har tidsutgått. Se till att enheten har rätt USB-firmware.",
|
||||
"@tcpStatus_connectingTo": {
|
||||
"placeholders": {
|
||||
"endpoint": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@tcpConnectionFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tcpHostHint": "192.168.40.10",
|
||||
"tcpHostLabel": "IP-adress",
|
||||
"tcpScreenTitle": "Anslut via TCP",
|
||||
"connectionChoiceTcpLabel": "TCP",
|
||||
"tcpPortLabel": "Port",
|
||||
"tcpPortHint": "5000",
|
||||
"tcpStatus_notConnected": "Ange slutpunkt och anslut",
|
||||
"tcpStatus_connectingTo": "Anslutning till {endpoint}...",
|
||||
"tcpErrorHostRequired": "IP-adress krävs.",
|
||||
"tcpErrorPortInvalid": "Porten måste vara mellan 1 och 65535.",
|
||||
"tcpErrorUnsupported": "TCP-transport fungerar inte på denna plattform.",
|
||||
"tcpErrorTimedOut": "TCP-anslutningen har tidsut gått.",
|
||||
"tcpConnectionFailed": "Fel vid TCP-anslutning: {error}",
|
||||
"map_showDiscoveryContacts": "Visa Discovery-kontakter",
|
||||
"map_setAsMyLocation": "Ange som min plats",
|
||||
"settings_privacy": "Inställningar för sekretess",
|
||||
"settings_allowAll": "Tillåt alla",
|
||||
"settings_privacySubtitle": "Kontrollera vilken information som delas.",
|
||||
"settings_telemetryEnvironmentMode": "Telemetri miljöläge",
|
||||
"settings_telemetryBaseMode": "Telemetribasläge",
|
||||
"settings_telemetryLocationMode": "Telemetritillstånd för plats",
|
||||
"settings_advertLocation": "Annonsplacering",
|
||||
"contact_info": "Kontaktinformation",
|
||||
"contact_settings": "Kontaktinställningar",
|
||||
"contact_telemetry": "Telemetri",
|
||||
"settings_denyAll": "Neka alla",
|
||||
"settings_allowByContact": "Tillåt via kontaktflaggor",
|
||||
"settings_privacySettingsDescription": "Välj vilken information din enhet delar med andra.",
|
||||
"contact_lastSeen": "Senast sedd",
|
||||
"contact_clearChat": "Rensa Chatt",
|
||||
"contact_teleEnv": "Telemetri Miljö",
|
||||
"settings_advertLocationSubtitle": "Inkludera plats i annonsen",
|
||||
"contact_teleEnvSubtitle": "Tillåt delning av miljösensordata",
|
||||
"contact_teleBase": "Telemetribas",
|
||||
"contact_teleBaseSubtitle": "Tillåt delning av batterinivå och grundläggande telemetri",
|
||||
"contact_teleLoc": "Telemetridata plats",
|
||||
"contact_teleLocSubtitle": "Tillåt delning av platsdata",
|
||||
"@settings_multiAck": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings_telemetryModeUpdated": "Telemetri-läge uppdaterat",
|
||||
"settings_multiAck": "Multi-ACKs: {value}"
|
||||
}
|
||||
|
||||
+1924
File diff suppressed because it is too large
Load Diff
+963
-373
File diff suppressed because it is too large
Load Diff
+84
-14
@@ -1,8 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'screens/chrome_required_screen.dart';
|
||||
import 'utils/platform_info.dart';
|
||||
|
||||
import 'connector/meshcore_connector.dart';
|
||||
import 'screens/scanner_screen.dart';
|
||||
import 'services/storage_service.dart';
|
||||
@@ -14,6 +18,9 @@ import 'services/ble_debug_log_service.dart';
|
||||
import 'services/app_debug_log_service.dart';
|
||||
import 'services/background_service.dart';
|
||||
import 'services/map_tile_cache_service.dart';
|
||||
import 'services/chat_text_scale_service.dart';
|
||||
import 'services/ui_view_state_service.dart';
|
||||
import 'services/timeout_prediction_service.dart';
|
||||
import 'storage/prefs_manager.dart';
|
||||
import 'utils/app_logger.dart';
|
||||
|
||||
@@ -27,12 +34,15 @@ void main() async {
|
||||
final storage = StorageService();
|
||||
final connector = MeshCoreConnector();
|
||||
final pathHistoryService = PathHistoryService(storage);
|
||||
final retryService = MessageRetryService(storage);
|
||||
final retryService = MessageRetryService();
|
||||
final appSettingsService = AppSettingsService();
|
||||
final bleDebugLogService = BleDebugLogService();
|
||||
final appDebugLogService = AppDebugLogService();
|
||||
final backgroundService = BackgroundService();
|
||||
final mapTileCacheService = MapTileCacheService();
|
||||
final chatTextScaleService = ChatTextScaleService();
|
||||
final uiViewStateService = UiViewStateService();
|
||||
final timeoutPredictionService = TimeoutPredictionService(storage);
|
||||
|
||||
// Load settings
|
||||
await appSettingsService.loadSettings();
|
||||
@@ -47,6 +57,11 @@ void main() async {
|
||||
final notificationService = NotificationService();
|
||||
await notificationService.initialize();
|
||||
await backgroundService.initialize();
|
||||
_registerThirdPartyLicenses();
|
||||
|
||||
await chatTextScaleService.initialize();
|
||||
await uiViewStateService.initialize();
|
||||
await timeoutPredictionService.initialize();
|
||||
|
||||
// Wire up connector with services
|
||||
connector.initialize(
|
||||
@@ -56,25 +71,53 @@ void main() async {
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
appDebugLogService: appDebugLogService,
|
||||
backgroundService: backgroundService,
|
||||
timeoutPredictionService: timeoutPredictionService,
|
||||
);
|
||||
|
||||
await connector.loadContactCache();
|
||||
await connector.loadChannelSettings();
|
||||
await connector.loadCachedChannels();
|
||||
|
||||
// Load persisted channel messages
|
||||
await connector.loadAllChannelMessages();
|
||||
await connector.loadUnreadState();
|
||||
|
||||
runApp(MeshCoreApp(
|
||||
connector: connector,
|
||||
retryService: retryService,
|
||||
pathHistoryService: pathHistoryService,
|
||||
storage: storage,
|
||||
appSettingsService: appSettingsService,
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
appDebugLogService: appDebugLogService,
|
||||
mapTileCacheService: mapTileCacheService,
|
||||
));
|
||||
runApp(
|
||||
MeshCoreApp(
|
||||
connector: connector,
|
||||
retryService: retryService,
|
||||
pathHistoryService: pathHistoryService,
|
||||
storage: storage,
|
||||
appSettingsService: appSettingsService,
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
appDebugLogService: appDebugLogService,
|
||||
mapTileCacheService: mapTileCacheService,
|
||||
chatTextScaleService: chatTextScaleService,
|
||||
uiViewStateService: uiViewStateService,
|
||||
timeoutPredictionService: timeoutPredictionService,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _registerThirdPartyLicenses() {
|
||||
LicenseRegistry.addLicense(() async* {
|
||||
yield const LicenseEntryWithLineBreaks(
|
||||
<String>['Open-Meteo Elevation API Data'],
|
||||
'''
|
||||
Data used by LOS elevation lookups is provided by Open-Meteo.
|
||||
|
||||
Open-Meteo terms and attribution:
|
||||
https://open-meteo.com/en/terms
|
||||
|
||||
Elevation API:
|
||||
https://open-meteo.com/en/docs/elevation-api
|
||||
|
||||
Attribution license reference:
|
||||
Creative Commons Attribution 4.0 International (CC BY 4.0)
|
||||
https://creativecommons.org/licenses/by/4.0/
|
||||
''',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class MeshCoreApp extends StatelessWidget {
|
||||
@@ -86,6 +129,9 @@ class MeshCoreApp extends StatelessWidget {
|
||||
final BleDebugLogService bleDebugLogService;
|
||||
final AppDebugLogService appDebugLogService;
|
||||
final MapTileCacheService mapTileCacheService;
|
||||
final ChatTextScaleService chatTextScaleService;
|
||||
final UiViewStateService uiViewStateService;
|
||||
final TimeoutPredictionService timeoutPredictionService;
|
||||
|
||||
const MeshCoreApp({
|
||||
super.key,
|
||||
@@ -97,6 +143,9 @@ class MeshCoreApp extends StatelessWidget {
|
||||
required this.bleDebugLogService,
|
||||
required this.appDebugLogService,
|
||||
required this.mapTileCacheService,
|
||||
required this.chatTextScaleService,
|
||||
required this.uiViewStateService,
|
||||
required this.timeoutPredictionService,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -109,8 +158,11 @@ class MeshCoreApp extends StatelessWidget {
|
||||
ChangeNotifierProvider.value(value: appSettingsService),
|
||||
ChangeNotifierProvider.value(value: bleDebugLogService),
|
||||
ChangeNotifierProvider.value(value: appDebugLogService),
|
||||
ChangeNotifierProvider.value(value: chatTextScaleService),
|
||||
ChangeNotifierProvider.value(value: uiViewStateService),
|
||||
Provider.value(value: storage),
|
||||
Provider.value(value: mapTileCacheService),
|
||||
ChangeNotifierProvider.value(value: timeoutPredictionService),
|
||||
],
|
||||
child: Consumer<AppSettingsService>(
|
||||
builder: (context, settingsService, child) {
|
||||
@@ -124,10 +176,15 @@ class MeshCoreApp extends StatelessWidget {
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
locale: _localeFromSetting(settingsService.settings.languageOverride),
|
||||
locale: _localeFromSetting(
|
||||
settingsService.settings.languageOverride,
|
||||
),
|
||||
theme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
||||
useMaterial3: true,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
@@ -135,9 +192,22 @@ class MeshCoreApp extends StatelessWidget {
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
snackBarTheme: const SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
),
|
||||
themeMode: _themeModeFromSetting(settingsService.settings.themeMode),
|
||||
home: const ScannerScreen(),
|
||||
themeMode: _themeModeFromSetting(
|
||||
settingsService.settings.themeMode,
|
||||
),
|
||||
builder: (context, child) {
|
||||
// Update notification service with resolved locale
|
||||
final locale = Localizations.localeOf(context);
|
||||
NotificationService().setLocale(locale);
|
||||
return child ?? const SizedBox.shrink();
|
||||
},
|
||||
home: (PlatformInfo.isWeb && !PlatformInfo.isChrome)
|
||||
? const ChromeRequiredScreen()
|
||||
: const ScannerScreen(),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
+102
-12
@@ -1,3 +1,16 @@
|
||||
enum UnitSystem { metric, imperial }
|
||||
|
||||
extension UnitSystemValue on UnitSystem {
|
||||
String get value {
|
||||
switch (this) {
|
||||
case UnitSystem.imperial:
|
||||
return 'imperial';
|
||||
case UnitSystem.metric:
|
||||
return 'metric';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class AppSettings {
|
||||
static const Object _unset = Object();
|
||||
|
||||
@@ -9,6 +22,8 @@ class AppSettings {
|
||||
final bool mapKeyPrefixEnabled;
|
||||
final String mapKeyPrefix;
|
||||
final bool mapShowMarkers;
|
||||
final bool mapShowGuessedLocations;
|
||||
final bool enableMessageTracing;
|
||||
final Map<String, double>? mapCacheBounds;
|
||||
final int mapCacheMinZoom;
|
||||
final int mapCacheMaxZoom;
|
||||
@@ -21,6 +36,12 @@ class AppSettings {
|
||||
final String? languageOverride; // null = system default
|
||||
final bool appDebugLogEnabled;
|
||||
final Map<String, String> batteryChemistryByDeviceId;
|
||||
final Map<String, String> batteryChemistryByRepeaterId;
|
||||
final UnitSystem unitSystem;
|
||||
final Set<String> mutedChannels;
|
||||
final bool mapShowDiscoveryContacts;
|
||||
final String tcpServerAddress;
|
||||
final int tcpServerPort;
|
||||
|
||||
AppSettings({
|
||||
this.clearPathOnMaxRetry = false,
|
||||
@@ -31,6 +52,8 @@ class AppSettings {
|
||||
this.mapKeyPrefixEnabled = false,
|
||||
this.mapKeyPrefix = '',
|
||||
this.mapShowMarkers = true,
|
||||
this.mapShowGuessedLocations = true,
|
||||
this.enableMessageTracing = false,
|
||||
this.mapCacheBounds,
|
||||
this.mapCacheMinZoom = 10,
|
||||
this.mapCacheMaxZoom = 15,
|
||||
@@ -43,7 +66,15 @@ class AppSettings {
|
||||
this.languageOverride,
|
||||
this.appDebugLogEnabled = false,
|
||||
Map<String, String>? batteryChemistryByDeviceId,
|
||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {};
|
||||
Map<String, String>? batteryChemistryByRepeaterId,
|
||||
this.unitSystem = UnitSystem.metric,
|
||||
Set<String>? mutedChannels,
|
||||
this.mapShowDiscoveryContacts = true,
|
||||
this.tcpServerAddress = '',
|
||||
this.tcpServerPort = 0,
|
||||
}) : batteryChemistryByDeviceId = batteryChemistryByDeviceId ?? {},
|
||||
batteryChemistryByRepeaterId = batteryChemistryByRepeaterId ?? {},
|
||||
mutedChannels = mutedChannels ?? {};
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
@@ -55,6 +86,8 @@ class AppSettings {
|
||||
'map_key_prefix_enabled': mapKeyPrefixEnabled,
|
||||
'map_key_prefix': mapKeyPrefix,
|
||||
'map_show_markers': mapShowMarkers,
|
||||
'map_show_guessed_locations': mapShowGuessedLocations,
|
||||
'enable_message_tracing': enableMessageTracing,
|
||||
'map_cache_bounds': mapCacheBounds,
|
||||
'map_cache_min_zoom': mapCacheMinZoom,
|
||||
'map_cache_max_zoom': mapCacheMaxZoom,
|
||||
@@ -67,22 +100,39 @@ class AppSettings {
|
||||
'language_override': languageOverride,
|
||||
'app_debug_log_enabled': appDebugLogEnabled,
|
||||
'battery_chemistry_by_device_id': batteryChemistryByDeviceId,
|
||||
'battery_chemistry_by_repeater_id': batteryChemistryByRepeaterId,
|
||||
'unit_system': unitSystem.value,
|
||||
'muted_channels': mutedChannels.toList(),
|
||||
'map_show_discovery_contacts': mapShowDiscoveryContacts,
|
||||
'tcp_server_address': tcpServerAddress,
|
||||
'tcp_server_port': tcpServerPort,
|
||||
};
|
||||
}
|
||||
|
||||
factory AppSettings.fromJson(Map<String, dynamic> json) {
|
||||
UnitSystem parseUnitSystem(dynamic value) {
|
||||
if (value is String && value.toLowerCase() == 'imperial') {
|
||||
return UnitSystem.imperial;
|
||||
}
|
||||
return UnitSystem.metric;
|
||||
}
|
||||
|
||||
return AppSettings(
|
||||
clearPathOnMaxRetry: json['clear_path_on_max_retry'] as bool? ?? false,
|
||||
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
|
||||
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
|
||||
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
|
||||
mapTimeFilterHours: (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
|
||||
mapTimeFilterHours:
|
||||
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
|
||||
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
|
||||
mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
|
||||
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
|
||||
mapShowGuessedLocations:
|
||||
json['map_show_guessed_locations'] as bool? ?? true,
|
||||
enableMessageTracing: json['enable_message_tracing'] as bool? ?? false,
|
||||
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
|
||||
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
|
||||
),
|
||||
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
|
||||
),
|
||||
mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10,
|
||||
mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15,
|
||||
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
|
||||
@@ -90,14 +140,31 @@ class AppSettings {
|
||||
notifyOnNewChannelMessage:
|
||||
json['notify_on_new_channel_message'] as bool? ?? true,
|
||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
autoRouteRotationEnabled:
|
||||
json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
themeMode: json['theme_mode'] as String? ?? 'system',
|
||||
languageOverride: json['language_override'] as String?,
|
||||
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
|
||||
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map(
|
||||
batteryChemistryByDeviceId:
|
||||
(json['battery_chemistry_by_device_id'] as Map?)?.map(
|
||||
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||
) ??
|
||||
{},
|
||||
batteryChemistryByRepeaterId:
|
||||
(json['battery_chemistry_by_repeater_id'] as Map?)?.map(
|
||||
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||
) ??
|
||||
{},
|
||||
unitSystem: parseUnitSystem(json['unit_system']),
|
||||
mutedChannels:
|
||||
((json['muted_channels'] as List?)
|
||||
?.map((e) => e.toString())
|
||||
.toSet()) ??
|
||||
{},
|
||||
mapShowDiscoveryContacts:
|
||||
json['map_show_discovery_contacts'] as bool? ?? true,
|
||||
tcpServerAddress: json['tcp_server_address'] as String? ?? '',
|
||||
tcpServerPort: json['tcp_server_port'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,6 +177,8 @@ class AppSettings {
|
||||
bool? mapKeyPrefixEnabled,
|
||||
String? mapKeyPrefix,
|
||||
bool? mapShowMarkers,
|
||||
bool? mapShowGuessedLocations,
|
||||
bool? enableMessageTracing,
|
||||
Object? mapCacheBounds = _unset,
|
||||
int? mapCacheMinZoom,
|
||||
int? mapCacheMaxZoom,
|
||||
@@ -122,6 +191,12 @@ class AppSettings {
|
||||
Object? languageOverride = _unset,
|
||||
bool? appDebugLogEnabled,
|
||||
Map<String, String>? batteryChemistryByDeviceId,
|
||||
Map<String, String>? batteryChemistryByRepeaterId,
|
||||
UnitSystem? unitSystem,
|
||||
Set<String>? mutedChannels,
|
||||
bool? mapShowDiscoveryContacts,
|
||||
String? tcpServerAddress,
|
||||
int? tcpServerPort,
|
||||
}) {
|
||||
return AppSettings(
|
||||
clearPathOnMaxRetry: clearPathOnMaxRetry ?? this.clearPathOnMaxRetry,
|
||||
@@ -132,8 +207,12 @@ class AppSettings {
|
||||
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
|
||||
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
|
||||
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
|
||||
mapCacheBounds:
|
||||
mapCacheBounds == _unset ? this.mapCacheBounds : mapCacheBounds as Map<String, double>?,
|
||||
mapShowGuessedLocations:
|
||||
mapShowGuessedLocations ?? this.mapShowGuessedLocations,
|
||||
enableMessageTracing: enableMessageTracing ?? this.enableMessageTracing,
|
||||
mapCacheBounds: mapCacheBounds == _unset
|
||||
? this.mapCacheBounds
|
||||
: mapCacheBounds as Map<String, double>?,
|
||||
mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom,
|
||||
mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom,
|
||||
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
|
||||
@@ -141,12 +220,23 @@ class AppSettings {
|
||||
notifyOnNewChannelMessage:
|
||||
notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage,
|
||||
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
||||
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||
autoRouteRotationEnabled:
|
||||
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
languageOverride:
|
||||
languageOverride == _unset ? this.languageOverride : languageOverride as String?,
|
||||
languageOverride: languageOverride == _unset
|
||||
? this.languageOverride
|
||||
: languageOverride as String?,
|
||||
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
|
||||
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
||||
batteryChemistryByDeviceId:
|
||||
batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
|
||||
batteryChemistryByRepeaterId:
|
||||
batteryChemistryByRepeaterId ?? this.batteryChemistryByRepeaterId,
|
||||
unitSystem: unitSystem ?? this.unitSystem,
|
||||
mutedChannels: mutedChannels ?? this.mutedChannels,
|
||||
mapShowDiscoveryContacts:
|
||||
mapShowDiscoveryContacts ?? this.mapShowDiscoveryContacts,
|
||||
tcpServerAddress: tcpServerAddress ?? this.tcpServerAddress,
|
||||
tcpServerPort: tcpServerPort ?? this.tcpServerPort,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+44
-5
@@ -1,16 +1,21 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class Channel {
|
||||
final int index;
|
||||
final String name;
|
||||
final Uint8List psk; // 16 bytes
|
||||
int unreadCount;
|
||||
|
||||
Channel({
|
||||
required this.index,
|
||||
required this.name,
|
||||
required this.psk,
|
||||
this.unreadCount = 0,
|
||||
});
|
||||
|
||||
String get pskHex => _bytesToHex(psk);
|
||||
@@ -36,11 +41,7 @@ class Channel {
|
||||
}
|
||||
|
||||
static Channel empty(int index) {
|
||||
return Channel(
|
||||
index: index,
|
||||
name: '',
|
||||
psk: Uint8List(16),
|
||||
);
|
||||
return Channel(index: index, name: '', psk: Uint8List(16));
|
||||
}
|
||||
|
||||
static Channel fromHex(int index, String name, String pskHex) {
|
||||
@@ -61,6 +62,44 @@ class Channel {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// Derive PSK from hashtag name using SHA256.
|
||||
/// The hashtag is normalized to include '#' prefix.
|
||||
/// Returns first 16 bytes of SHA256 hash as PSK.
|
||||
static Uint8List derivePskFromHashtag(String hashtag) {
|
||||
final name = hashtag.startsWith('#') ? hashtag : '#$hashtag';
|
||||
final hash = crypto.sha256.convert(utf8.encode(name)).bytes;
|
||||
return Uint8List.fromList(hash.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Derive PSK for community public channel using HMAC-SHA256.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:__public__")[:16]
|
||||
///
|
||||
/// This creates a channel that is "public" only to members who have
|
||||
/// the community secret. Outsiders see only opaque IDs.
|
||||
static Uint8List deriveCommunityPublicPsk(Uint8List secret) {
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:__public__'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Derive PSK for community hashtag channel using HMAC-SHA256.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:" + normalized_name)[:16]
|
||||
///
|
||||
/// Community hashtag channels are deterministic for all members
|
||||
/// (same name => same id) but impossible to enumerate/guess without K.
|
||||
static Uint8List deriveCommunityHashtagPsk(Uint8List secret, String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:$normalized'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Normalize a hashtag name for consistent community PSK derivation.
|
||||
/// Strips leading #, converts to lowercase, trims whitespace.
|
||||
static String _normalizeCommunityHashtag(String hashtag) {
|
||||
return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
static String formatPskHex(Uint8List psk) {
|
||||
return _bytesToHex(psk);
|
||||
}
|
||||
|
||||
@@ -59,15 +59,18 @@ class ChannelMessage {
|
||||
this.replyToSenderName,
|
||||
this.replyToText,
|
||||
Map<String, int>? reactions,
|
||||
}) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
|
||||
reactions = reactions ?? {},
|
||||
pathBytes = pathBytes ?? Uint8List(0),
|
||||
pathVariants = _mergePathVariants(
|
||||
pathBytes ?? Uint8List(0),
|
||||
pathVariants,
|
||||
);
|
||||
}) : messageId =
|
||||
messageId ??
|
||||
'${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
|
||||
reactions = reactions ?? {},
|
||||
pathBytes = pathBytes ?? Uint8List(0),
|
||||
pathVariants = _mergePathVariants(
|
||||
pathBytes ?? Uint8List(0),
|
||||
pathVariants,
|
||||
);
|
||||
|
||||
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null;
|
||||
String? get senderKeyHex =>
|
||||
senderKey != null ? pubKeyToHex(senderKey!) : null;
|
||||
|
||||
ChannelMessage copyWith({
|
||||
ChannelMessageStatus? status,
|
||||
@@ -125,8 +128,10 @@ class ChannelMessage {
|
||||
final hasPathBytesFlag = (data[2] & 0x01) != 0;
|
||||
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
|
||||
final hasValidTxtType =
|
||||
cursor < data.length && (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
|
||||
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && canFitPath) {
|
||||
cursor < data.length &&
|
||||
(data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
|
||||
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) &&
|
||||
canFitPath) {
|
||||
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
|
||||
cursor += pathLen;
|
||||
}
|
||||
@@ -162,7 +167,8 @@ class ChannelMessage {
|
||||
final potentialSender = text.substring(0, colonIndex);
|
||||
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
|
||||
senderName = potentialSender;
|
||||
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
|
||||
final offset =
|
||||
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
|
||||
? colonIndex + 2
|
||||
: colonIndex + 1;
|
||||
actualText = text.substring(offset);
|
||||
@@ -184,7 +190,11 @@ class ChannelMessage {
|
||||
);
|
||||
}
|
||||
|
||||
static ChannelMessage outgoing(String text, String senderName, int channelIndex) {
|
||||
static ChannelMessage outgoing(
|
||||
String text,
|
||||
String senderName,
|
||||
int channelIndex,
|
||||
) {
|
||||
return ChannelMessage(
|
||||
senderKey: null,
|
||||
senderName: senderName,
|
||||
@@ -249,8 +259,5 @@ class ReplyInfo {
|
||||
final String mentionedNode;
|
||||
final String actualMessage;
|
||||
|
||||
ReplyInfo({
|
||||
required this.mentionedNode,
|
||||
required this.actualMessage,
|
||||
});
|
||||
ReplyInfo({required this.mentionedNode, required this.actualMessage});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
|
||||
/// Represents a community with a shared secret for deriving channel PSKs.
|
||||
///
|
||||
/// A Community is a namespace with a shared secret K (32 random bytes),
|
||||
/// distributed via QR code. Members can create Community Public Channels
|
||||
/// and Community Hashtag Channels that are opaque to outsiders.
|
||||
class Community {
|
||||
/// Unique identifier for local storage
|
||||
final String id;
|
||||
|
||||
/// Display name for the community
|
||||
final String name;
|
||||
|
||||
/// The 32-byte shared secret (K)
|
||||
final Uint8List secret;
|
||||
|
||||
/// Timestamp when the community was created/joined
|
||||
final DateTime createdAt;
|
||||
|
||||
/// List of hashtag channel names (without #) that have been added
|
||||
final List<String> hashtagChannels;
|
||||
|
||||
Community({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.secret,
|
||||
required this.createdAt,
|
||||
List<String>? hashtagChannels,
|
||||
}) : hashtagChannels = hashtagChannels ?? [];
|
||||
|
||||
/// Generate a new community with a random 32-byte secret
|
||||
factory Community.create({required String id, required String name}) {
|
||||
final random = Random.secure();
|
||||
final secret = Uint8List(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
secret[i] = random.nextInt(256);
|
||||
}
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: secret,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a community from QR code JSON data
|
||||
factory Community.fromQrData(String id, String qrData) {
|
||||
final json = jsonDecode(qrData) as Map<String, dynamic>;
|
||||
if (json['type'] != 'meshcore_community') {
|
||||
throw const FormatException('Invalid QR code type');
|
||||
}
|
||||
if (json['v'] != 1) {
|
||||
throw const FormatException('Unsupported QR code version');
|
||||
}
|
||||
|
||||
final name = json['name'] as String;
|
||||
final secretBase64 = json['k'] as String;
|
||||
final secret = base64Url.decode(secretBase64);
|
||||
|
||||
if (secret.length != 32) {
|
||||
throw const FormatException('Invalid secret length');
|
||||
}
|
||||
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: Uint8List.fromList(secret),
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Parse a community from storage JSON
|
||||
factory Community.fromJson(Map<String, dynamic> json) {
|
||||
return Community(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
secret: base64Decode(json['secret'] as String),
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(json['created_at'] as int),
|
||||
hashtagChannels:
|
||||
(json['hashtag_channels'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to JSON for storage
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'secret': base64Encode(secret),
|
||||
'created_at': createdAt.millisecondsSinceEpoch,
|
||||
'hashtag_channels': hashtagChannels,
|
||||
};
|
||||
}
|
||||
|
||||
/// Generate QR code JSON payload for sharing
|
||||
String toQrJson() {
|
||||
return jsonEncode({
|
||||
'v': 1,
|
||||
'type': 'meshcore_community',
|
||||
'name': name,
|
||||
'k': base64Url.encode(secret),
|
||||
});
|
||||
}
|
||||
|
||||
/// Derive the public Community ID from the secret.
|
||||
/// This is safe to display/log since it's one-way derived.
|
||||
/// CID = SHA256("community:v1" || K)
|
||||
String get communityId {
|
||||
final data = utf8.encode('community:v1') + secret;
|
||||
final hash = crypto.sha256.convert(data).bytes;
|
||||
return _bytesToHex(Uint8List.fromList(hash));
|
||||
}
|
||||
|
||||
/// Short version of community ID for display (first 8 chars)
|
||||
String get shortCommunityId => communityId.substring(0, 8);
|
||||
|
||||
/// Derive PSK for community public channel.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:__public__")[:16]
|
||||
Uint8List deriveCommunityPublicPsk() {
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:__public__'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Derive PSK for community hashtag channel.
|
||||
/// PSK = HMAC-SHA256(K, "channel:v1:" + normalized_name)[:16]
|
||||
Uint8List deriveCommunityHashtagPsk(String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
final hmac = crypto.Hmac(crypto.sha256, secret);
|
||||
final digest = hmac.convert(utf8.encode('channel:v1:$normalized'));
|
||||
return Uint8List.fromList(digest.bytes.sublist(0, 16));
|
||||
}
|
||||
|
||||
/// Check if QR data is valid community data
|
||||
static bool isValidQrData(String data) {
|
||||
try {
|
||||
final json = jsonDecode(data) as Map<String, dynamic>;
|
||||
if (json['type'] != 'meshcore_community') return false;
|
||||
if (json['v'] != 1) return false;
|
||||
if (json['name'] == null || (json['name'] as String).isEmpty) {
|
||||
return false;
|
||||
}
|
||||
if (json['k'] == null) return false;
|
||||
final secret = base64Url.decode(json['k'] as String);
|
||||
return secret.length == 32;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a hashtag name for consistent PSK derivation.
|
||||
/// Strips leading #, converts to lowercase, trims whitespace.
|
||||
static String _normalizeCommunityHashtag(String hashtag) {
|
||||
return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
/// Add a hashtag channel to this community's list
|
||||
Community addHashtagChannel(String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
if (hashtagChannels.contains(normalized)) {
|
||||
return this;
|
||||
}
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: secret,
|
||||
createdAt: createdAt,
|
||||
hashtagChannels: [...hashtagChannels, normalized],
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove a hashtag channel from this community's list
|
||||
Community removeHashtagChannel(String hashtag) {
|
||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: secret,
|
||||
createdAt: createdAt,
|
||||
hashtagChannels: hashtagChannels.where((h) => h != normalized).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of this community with a new secret
|
||||
Community withNewSecret(Uint8List newSecret) {
|
||||
return Community(
|
||||
id: id,
|
||||
name: name,
|
||||
secret: newSecret,
|
||||
createdAt: createdAt,
|
||||
hashtagChannels: hashtagChannels,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of this community with a regenerated random secret
|
||||
Community withRegeneratedSecret() {
|
||||
final random = Random.secure();
|
||||
final newSecret = Uint8List(32);
|
||||
for (int i = 0; i < 32; i++) {
|
||||
newSecret[i] = random.nextInt(256);
|
||||
}
|
||||
return withNewSecret(newSecret);
|
||||
}
|
||||
|
||||
/// Extract secret from QR data (for updating existing community)
|
||||
static Uint8List? extractSecretFromQrData(String qrData) {
|
||||
try {
|
||||
final json = jsonDecode(qrData) as Map<String, dynamic>;
|
||||
if (json['type'] != 'meshcore_community') return null;
|
||||
if (json['v'] != 1) return null;
|
||||
final secretBase64 = json['k'] as String;
|
||||
final secret = base64Url.decode(secretBase64);
|
||||
if (secret.length != 32) return null;
|
||||
return Uint8List.fromList(secret);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String _bytesToHex(Uint8List bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is Community && runtimeType == other.runtimeType && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
}
|
||||
+91
-42
@@ -1,23 +1,30 @@
|
||||
import 'dart:typed_data';
|
||||
import 'package:meshcore_open/utils/app_logger.dart';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
|
||||
class Contact {
|
||||
final Uint8List publicKey;
|
||||
final String name;
|
||||
final int type;
|
||||
final int flags;
|
||||
final int pathLength; // -1 = flood, 0+ = direct hops (from device)
|
||||
final Uint8List path; // Path bytes from device
|
||||
final int? pathOverride; // User's path override: -1 = force flood, null = auto
|
||||
final int?
|
||||
pathOverride; // User's path override: -1 = force flood, null = auto
|
||||
final Uint8List? pathOverrideBytes; // User's path override bytes
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final DateTime lastSeen;
|
||||
final DateTime lastMessageAt;
|
||||
final bool isActive;
|
||||
final Uint8List? rawPacket;
|
||||
|
||||
Contact({
|
||||
required this.publicKey,
|
||||
required this.name,
|
||||
required this.type,
|
||||
this.flags = 0,
|
||||
required this.pathLength,
|
||||
required this.path,
|
||||
this.pathOverride,
|
||||
@@ -26,6 +33,8 @@ class Contact {
|
||||
this.longitude,
|
||||
required this.lastSeen,
|
||||
DateTime? lastMessageAt,
|
||||
this.isActive = true,
|
||||
this.rawPacket,
|
||||
}) : lastMessageAt = lastMessageAt ?? lastSeen;
|
||||
|
||||
String get publicKeyHex => pubKeyToHex(publicKey);
|
||||
@@ -56,12 +65,24 @@ class Contact {
|
||||
return '$pathLength hops';
|
||||
}
|
||||
|
||||
bool get hasLocation => latitude != null && longitude != null;
|
||||
bool get hasLocation {
|
||||
const double epsilon = 1e-6;
|
||||
final lat = latitude ?? 0.0;
|
||||
final lon = longitude ?? 0.0;
|
||||
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
|
||||
lat >= -90.0 &&
|
||||
lat <= 90.0 &&
|
||||
lon >= -180.0 &&
|
||||
lon <= 180.0;
|
||||
}
|
||||
|
||||
bool get isFavorite => (flags & contactFlagFavorite) != 0;
|
||||
|
||||
Contact copyWith({
|
||||
Uint8List? publicKey,
|
||||
String? name,
|
||||
int? type,
|
||||
int? flags,
|
||||
int? pathLength,
|
||||
Uint8List? path,
|
||||
int? pathOverride,
|
||||
@@ -71,38 +92,55 @@ class Contact {
|
||||
double? longitude,
|
||||
DateTime? lastSeen,
|
||||
DateTime? lastMessageAt,
|
||||
bool? isActive,
|
||||
Uint8List? rawPacket,
|
||||
}) {
|
||||
return Contact(
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
flags: flags ?? this.flags,
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
path: path ?? this.path,
|
||||
pathOverride: clearPathOverride ? null : (pathOverride ?? this.pathOverride),
|
||||
pathOverrideBytes: clearPathOverride ? null : (pathOverrideBytes ?? this.pathOverrideBytes),
|
||||
pathOverride: clearPathOverride
|
||||
? null
|
||||
: (pathOverride ?? this.pathOverride),
|
||||
pathOverrideBytes: clearPathOverride
|
||||
? null
|
||||
: (pathOverrideBytes ?? this.pathOverrideBytes),
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
lastSeen: lastSeen ?? this.lastSeen,
|
||||
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
|
||||
isActive: isActive ?? this.isActive,
|
||||
rawPacket: rawPacket ?? this.rawPacket,
|
||||
);
|
||||
}
|
||||
|
||||
String get pathIdList {
|
||||
final pathBytes = _pathBytesForDisplay;
|
||||
final pathBytes = pathBytesForDisplay;
|
||||
if (pathBytes.isEmpty) return '';
|
||||
final parts = <String>[];
|
||||
final groupSize = pathHashSize;
|
||||
for (int i = 0; i < pathBytes.length; i += groupSize) {
|
||||
final end = (i + groupSize) <= pathBytes.length ? (i + groupSize) : pathBytes.length;
|
||||
final end = (i + groupSize) <= pathBytes.length
|
||||
? (i + groupSize)
|
||||
: pathBytes.length;
|
||||
final chunk = pathBytes.sublist(i, end);
|
||||
parts.add(
|
||||
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(),
|
||||
chunk
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(),
|
||||
);
|
||||
}
|
||||
return parts.join(',');
|
||||
}
|
||||
|
||||
Uint8List get _pathBytesForDisplay {
|
||||
String get shortPubKeyHex {
|
||||
return "<${publicKeyHex.substring(0, 8)}...${publicKeyHex.substring(publicKeyHex.length - 8)}>";
|
||||
}
|
||||
|
||||
Uint8List get pathBytesForDisplay {
|
||||
if (pathOverride != null) {
|
||||
if (pathOverride! < 0) return Uint8List(0);
|
||||
return pathOverrideBytes ?? Uint8List(0);
|
||||
@@ -111,43 +149,51 @@ class Contact {
|
||||
}
|
||||
|
||||
static Contact? fromFrame(Uint8List data) {
|
||||
if (data.length < contactFrameSize) return null;
|
||||
if (data[0] != respCodeContact) return null;
|
||||
if (data.isEmpty) return null;
|
||||
final reader = BufferReader(data);
|
||||
try {
|
||||
final respCode = reader.readByte();
|
||||
if (respCode != respCodeContact && respCode != pushCodeNewAdvert) {
|
||||
return null;
|
||||
}
|
||||
final pubKey = reader.readBytes(pubKeySize);
|
||||
final type = reader.readByte();
|
||||
final flags = reader.readByte();
|
||||
final pathLen = reader.readByte();
|
||||
final safePathLen = pathLen > 0
|
||||
? (pathLen > maxPathSize ? maxPathSize : pathLen)
|
||||
: 0;
|
||||
final pathBytes = reader.readBytes(maxPathSize).sublist(0, safePathLen);
|
||||
final name = reader.readCStringGreedy(maxNameSize);
|
||||
|
||||
final pubKey = Uint8List.fromList(
|
||||
data.sublist(contactPubKeyOffset, contactPubKeyOffset + pubKeySize),
|
||||
);
|
||||
final type = data[contactTypeOffset];
|
||||
final pathLen = data[contactPathLenOffset].toSigned(8);
|
||||
final safePathLen = pathLen > 0
|
||||
? (pathLen > maxPathSize ? maxPathSize : pathLen)
|
||||
: 0;
|
||||
final pathBytes = safePathLen > 0
|
||||
? Uint8List.fromList(
|
||||
data.sublist(contactPathOffset, contactPathOffset + safePathLen),
|
||||
)
|
||||
: Uint8List(0);
|
||||
final name = readCString(data, contactNameOffset, maxNameSize);
|
||||
final lastmod = readUint32LE(data, contactLastmodOffset);
|
||||
final lastMod = reader.readUInt32LE();
|
||||
|
||||
double? lat, lon;
|
||||
final latRaw = readInt32LE(data, contactLatOffset);
|
||||
final lonRaw = readInt32LE(data, contactLonOffset);
|
||||
if (latRaw != 0 || lonRaw != 0) {
|
||||
lat = latRaw / 1e6;
|
||||
lon = lonRaw / 1e6;
|
||||
double? lat, lon;
|
||||
final latRaw = reader.readInt32LE();
|
||||
final lonRaw = reader.readInt32LE();
|
||||
|
||||
if (latRaw != 0 || lonRaw != 0) {
|
||||
lat = latRaw / 1e6;
|
||||
lon = lonRaw / 1e6;
|
||||
}
|
||||
|
||||
return Contact(
|
||||
publicKey: pubKey,
|
||||
name: name.isEmpty ? 'Unknown' : name,
|
||||
type: type,
|
||||
flags: flags,
|
||||
pathLength: pathLen > 0 ? (pathLen > maxPathSize ? -1 : pathLen) : -1,
|
||||
path: pathBytes,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastMod * 1000),
|
||||
isActive: true,
|
||||
rawPacket: null,
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.error('Failed to parse contact frame: $e');
|
||||
return null;
|
||||
}
|
||||
|
||||
return Contact(
|
||||
publicKey: pubKey,
|
||||
name: name.isEmpty ? 'Unknown' : name,
|
||||
type: type,
|
||||
pathLength: pathLen,
|
||||
path: pathBytes,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastmod * 1000),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -156,4 +202,7 @@ class Contact {
|
||||
|
||||
@override
|
||||
int get hashCode => publicKeyHex.hashCode;
|
||||
bool get teleBaseEnabled => (flags & contactFlagTeleBase) != 0;
|
||||
bool get teleLocEnabled => (flags & contactFlagTeleLoc) != 0;
|
||||
bool get teleEnvEnabled => (flags & contactFlagTeleEnv) != 0;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,9 @@ class ContactGroup {
|
||||
final String name;
|
||||
final List<String> memberKeys;
|
||||
|
||||
const ContactGroup({
|
||||
required this.name,
|
||||
required this.memberKeys,
|
||||
});
|
||||
const ContactGroup({required this.name, required this.memberKeys});
|
||||
|
||||
ContactGroup copyWith({
|
||||
String? name,
|
||||
List<String>? memberKeys,
|
||||
}) {
|
||||
ContactGroup copyWith({String? name, List<String>? memberKeys}) {
|
||||
return ContactGroup(
|
||||
name: name ?? this.name,
|
||||
memberKeys: memberKeys ?? List<String>.from(this.memberKeys),
|
||||
@@ -18,16 +12,12 @@ class ContactGroup {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'members': memberKeys,
|
||||
};
|
||||
return {'name': name, 'members': memberKeys};
|
||||
}
|
||||
|
||||
factory ContactGroup.fromJson(Map<String, dynamic> json) {
|
||||
final members = (json['members'] as List?)
|
||||
?.map((value) => value.toString())
|
||||
.toList() ??
|
||||
final members =
|
||||
(json['members'] as List?)?.map((value) => value.toString()).toList() ??
|
||||
<String>[];
|
||||
return ContactGroup(
|
||||
name: json['name'] as String? ?? '',
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
class DeliveryObservation {
|
||||
final String contactKey;
|
||||
final int pathLength;
|
||||
final int messageBytes;
|
||||
final int secondsSinceLastRx;
|
||||
final bool isFlood;
|
||||
final int deliveryMs;
|
||||
final DateTime timestamp;
|
||||
|
||||
DeliveryObservation({
|
||||
required this.contactKey,
|
||||
required this.pathLength,
|
||||
required this.messageBytes,
|
||||
required this.secondsSinceLastRx,
|
||||
required this.isFlood,
|
||||
required this.deliveryMs,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'contact_key': contactKey,
|
||||
'path_length': pathLength,
|
||||
'message_bytes': messageBytes,
|
||||
'seconds_since_last_rx': secondsSinceLastRx,
|
||||
'is_flood': isFlood,
|
||||
'delivery_ms': deliveryMs,
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
factory DeliveryObservation.fromJson(Map<String, dynamic> json) {
|
||||
return DeliveryObservation(
|
||||
contactKey: json['contact_key'] as String,
|
||||
pathLength: json['path_length'] as int,
|
||||
messageBytes: json['message_bytes'] as int,
|
||||
secondsSinceLastRx: json['seconds_since_last_rx'] as int? ?? 0,
|
||||
isFlood: json['is_flood'] as bool,
|
||||
deliveryMs: json['delivery_ms'] as int,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -43,9 +43,9 @@ class Message {
|
||||
Uint8List? pathBytes,
|
||||
Uint8List? fourByteRoomContactKey,
|
||||
Map<String, int>? reactions,
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0),
|
||||
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
|
||||
reactions = reactions ?? {};
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0),
|
||||
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
|
||||
reactions = reactions ?? {};
|
||||
|
||||
String get senderKeyHex => pubKeyToHex(senderKey);
|
||||
|
||||
@@ -80,7 +80,8 @@ class Message {
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
reactions: reactions ?? this.reactions,
|
||||
fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey,
|
||||
fourByteRoomContactKey:
|
||||
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ class PathRecord {
|
||||
tripTimeMs: json['trip_time_ms'] as int,
|
||||
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||
wasFloodDiscovery: json['was_flood'] as bool,
|
||||
pathBytes: (json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
||||
pathBytes:
|
||||
(json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
|
||||
successCount: json['success_count'] as int? ?? 0,
|
||||
failureCount: json['failure_count'] as int? ?? 0,
|
||||
);
|
||||
@@ -65,14 +66,15 @@ class ContactPathHistory {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'recent_paths': recentPaths.map((p) => p.toJson()).toList(),
|
||||
};
|
||||
return {'recent_paths': recentPaths.map((p) => p.toJson()).toList()};
|
||||
}
|
||||
|
||||
factory ContactPathHistory.fromJson(
|
||||
String contactPubKeyHex, Map<String, dynamic> json) {
|
||||
final pathsList = (json['recent_paths'] as List?)
|
||||
String contactPubKeyHex,
|
||||
Map<String, dynamic> json,
|
||||
) {
|
||||
final pathsList =
|
||||
(json['recent_paths'] as List?)
|
||||
?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
+183
-29
@@ -59,46 +59,200 @@ class RadioSettings {
|
||||
required this.txPowerDbm,
|
||||
});
|
||||
|
||||
// Preset configurations
|
||||
static RadioSettings get preset915MHz => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
// Regional preset configurations
|
||||
static final List<(String, RadioSettings)> presets = [
|
||||
(
|
||||
'Australia',
|
||||
RadioSettings(
|
||||
frequencyMHz: 915.8,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf10,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Australia (Narrow)',
|
||||
RadioSettings(
|
||||
frequencyMHz: 916.575,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get preset868MHz => RadioSettings(
|
||||
frequencyMHz: 868.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Australia SA, WA, QLD',
|
||||
RadioSettings(
|
||||
frequencyMHz: 923.125,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Czech Republic',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.432,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
);
|
||||
|
||||
static RadioSettings get preset433MHz => RadioSettings(
|
||||
),
|
||||
),
|
||||
(
|
||||
'EU 433MHz',
|
||||
RadioSettings(
|
||||
frequencyMHz: 433.650,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'EU/UK (Long Range)',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.525,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'EU/UK (Medium Range)',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.525,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf10,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'EU/UK (Narrow)',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.618,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'New Zealand',
|
||||
RadioSettings(
|
||||
frequencyMHz: 917.375,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'New Zealand (Narrow)',
|
||||
RadioSettings(
|
||||
frequencyMHz: 917.375,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Portugal 433',
|
||||
RadioSettings(
|
||||
frequencyMHz: 433.375,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf9,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Portugal 869',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.618,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Switzerland',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.618,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'USA Arizona',
|
||||
RadioSettings(
|
||||
frequencyMHz: 908.205,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf10,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'USA/Canada',
|
||||
RadioSettings(
|
||||
frequencyMHz: 910.525,
|
||||
bandwidth: LoRaBandwidth.bw62_5,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Vietnam',
|
||||
RadioSettings(
|
||||
frequencyMHz: 920.250,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
),
|
||||
),
|
||||
// Off-grid repeat presets (valid client_repeat frequencies)
|
||||
(
|
||||
'Off-Grid 433',
|
||||
RadioSettings(
|
||||
frequencyMHz: 433.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get presetLongRange => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw125,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf12,
|
||||
codingRate: LoRaCodingRate.cr4_8,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
|
||||
static RadioSettings get presetFastSpeed => RadioSettings(
|
||||
frequencyMHz: 915.0,
|
||||
bandwidth: LoRaBandwidth.bw500,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Off-Grid 869',
|
||||
RadioSettings(
|
||||
frequencyMHz: 869.0,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 14,
|
||||
),
|
||||
),
|
||||
(
|
||||
'Off-Grid 918',
|
||||
RadioSettings(
|
||||
frequencyMHz: 918.0,
|
||||
bandwidth: LoRaBandwidth.bw250,
|
||||
spreadingFactor: LoRaSpreadingFactor.sf11,
|
||||
codingRate: LoRaCodingRate.cr4_5,
|
||||
txPowerDbm: 20,
|
||||
);
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
int get frequencyHz => (frequencyMHz * 1000).round();
|
||||
int get bandwidthHz => bandwidth.hz;
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/app_debug_log_service.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
|
||||
class AppDebugLogScreen extends StatelessWidget {
|
||||
const AppDebugLogScreen({super.key});
|
||||
@@ -17,7 +18,7 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.debugLog_appTitle),
|
||||
title: AdaptiveAppBarTitle(context.l10n.debugLog_appTitle),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
@@ -26,8 +27,10 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
onPressed: hasEntries
|
||||
? () async {
|
||||
final text = entries
|
||||
.map((entry) =>
|
||||
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}')
|
||||
.map(
|
||||
(entry) =>
|
||||
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}',
|
||||
)
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
@@ -53,7 +56,7 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
child: hasEntries
|
||||
? ListView.separated(
|
||||
itemCount: entries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = entries[index];
|
||||
return ListTile(
|
||||
@@ -61,11 +64,17 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
leading: _buildLevelIcon(entry.level),
|
||||
title: Text(
|
||||
'[${entry.tag}] ${entry.message}',
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
entry.formattedTime,
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -74,16 +83,26 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]),
|
||||
Icon(
|
||||
Icons.bug_report_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey[400],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.l10n.debugLog_noEntries,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.debugLog_enableInSettings,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[500],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -99,7 +118,11 @@ class AppDebugLogScreen extends StatelessWidget {
|
||||
case AppDebugLogLevel.info:
|
||||
return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
|
||||
case AppDebugLogLevel.warning:
|
||||
return const Icon(Icons.warning_amber_outlined, size: 18, color: Colors.orange);
|
||||
return const Icon(
|
||||
Icons.warning_amber_outlined,
|
||||
size: 18,
|
||||
color: Colors.orange,
|
||||
);
|
||||
case AppDebugLogLevel.error:
|
||||
return const Icon(Icons.error_outline, size: 18, color: Colors.red);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import 'map_cache_screen.dart';
|
||||
|
||||
class AppSettingsScreen extends StatelessWidget {
|
||||
@@ -14,7 +16,7 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.appSettings_title),
|
||||
title: AdaptiveAppBarTitle(context.l10n.appSettings_title),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
@@ -43,7 +45,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildAppearanceCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -58,7 +63,9 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.brightness_6_outlined),
|
||||
title: Text(context.l10n.appSettings_theme),
|
||||
subtitle: Text(_themeModeLabel(context, settingsService.settings.themeMode)),
|
||||
subtitle: Text(
|
||||
_themeModeLabel(context, settingsService.settings.themeMode),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showThemeModeDialog(context, settingsService),
|
||||
),
|
||||
@@ -66,16 +73,36 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.language_outlined),
|
||||
title: Text(context.l10n.appSettings_language),
|
||||
subtitle: Text(_languageLabel(context, settingsService.settings.languageOverride)),
|
||||
subtitle: Text(
|
||||
_languageLabel(
|
||||
context,
|
||||
settingsService.settings.languageOverride,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showLanguageDialog(context, settingsService),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.location_searching),
|
||||
title: Text(context.l10n.appSettings_enableMessageTracing),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_enableMessageTracingSubtitle,
|
||||
),
|
||||
value: settingsService.settings.enableMessageTracing,
|
||||
onChanged: (value) {
|
||||
settingsService.setEnableMessageTracing(value);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotificationsCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildNotificationsCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -90,17 +117,22 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.notifications_outlined),
|
||||
title: Text(context.l10n.appSettings_enableNotifications),
|
||||
subtitle: Text(context.l10n.appSettings_enableNotificationsSubtitle),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_enableNotificationsSubtitle,
|
||||
),
|
||||
value: settingsService.settings.notificationsEnabled,
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
// Request permission when enabling
|
||||
final granted = await NotificationService().requestPermissions();
|
||||
final granted = await NotificationService()
|
||||
.requestPermissions();
|
||||
if (!granted) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.appSettings_notificationPermissionDenied),
|
||||
content: Text(
|
||||
context.l10n.appSettings_notificationPermissionDenied,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -113,9 +145,11 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_notificationsEnabled
|
||||
: context.l10n.appSettings_notificationsDisabled),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_notificationsEnabled
|
||||
: context.l10n.appSettings_notificationsDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -126,18 +160,24 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.message_outlined,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.appSettings_messageNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_messageNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewMessage,
|
||||
@@ -151,18 +191,24 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.forum_outlined,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.appSettings_channelMessageNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_channelMessageNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewChannelMessage,
|
||||
@@ -176,18 +222,24 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.cell_tower,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
context.l10n.appSettings_advertisementNotifications,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_advertisementNotificationsSubtitle,
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
color: settingsService.settings.notificationsEnabled
|
||||
? null
|
||||
: Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewAdvert,
|
||||
@@ -202,7 +254,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessagingCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildMessagingCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -217,15 +272,19 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
SwitchListTile(
|
||||
secondary: const Icon(Icons.refresh_outlined),
|
||||
title: Text(context.l10n.appSettings_clearPathOnMaxRetry),
|
||||
subtitle: Text(context.l10n.appSettings_clearPathOnMaxRetrySubtitle),
|
||||
subtitle: Text(
|
||||
context.l10n.appSettings_clearPathOnMaxRetrySubtitle,
|
||||
),
|
||||
value: settingsService.settings.clearPathOnMaxRetry,
|
||||
onChanged: (value) {
|
||||
settingsService.setClearPathOnMaxRetry(value);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_pathsWillBeCleared
|
||||
: context.l10n.appSettings_pathsWillNotBeCleared),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_pathsWillBeCleared
|
||||
: context.l10n.appSettings_pathsWillNotBeCleared,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -241,9 +300,11 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
settingsService.setAutoRouteRotationEnabled(value);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_autoRouteRotationEnabled
|
||||
: context.l10n.appSettings_autoRouteRotationDisabled),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_autoRouteRotationEnabled
|
||||
: context.l10n.appSettings_autoRouteRotationDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -254,7 +315,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMapSettingsCard(BuildContext context, AppSettingsService settingsService) {
|
||||
Widget _buildMapSettingsCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -302,12 +366,26 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
subtitle: Text(
|
||||
settingsService.settings.mapTimeFilterHours == 0
|
||||
? context.l10n.appSettings_timeFilterShowAll
|
||||
: context.l10n.appSettings_timeFilterShowLast(settingsService.settings.mapTimeFilterHours.toInt()),
|
||||
: context.l10n.appSettings_timeFilterShowLast(
|
||||
settingsService.settings.mapTimeFilterHours.toInt(),
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showTimeFilterDialog(context, settingsService),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.straighten),
|
||||
title: Text(context.l10n.appSettings_unitsTitle),
|
||||
subtitle: Text(
|
||||
settingsService.settings.unitSystem == UnitSystem.imperial
|
||||
? context.l10n.appSettings_unitsImperial
|
||||
: context.l10n.appSettings_unitsMetric,
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showUnitsDialog(context, settingsService),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.download_outlined),
|
||||
title: Text(context.l10n.appSettings_offlineMapCache),
|
||||
@@ -332,6 +410,7 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed rendering issues
|
||||
Widget _buildBatteryCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
@@ -339,13 +418,15 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
) {
|
||||
final deviceId = connector.deviceId;
|
||||
final isConnected = connector.isConnected && deviceId != null;
|
||||
final selection =
|
||||
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
|
||||
final selection = isConnected
|
||||
? settingsService.batteryChemistryForDevice(deviceId)
|
||||
: 'nmc';
|
||||
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text(
|
||||
@@ -353,20 +434,38 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
|
||||
// Main tile (icon + text only)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.battery_full),
|
||||
title: Text(context.l10n.appSettings_batteryChemistry),
|
||||
subtitle: Text(
|
||||
isConnected
|
||||
? context.l10n.appSettings_batteryChemistryPerDevice(connector.deviceDisplayName)
|
||||
? context.l10n.appSettings_batteryChemistryPerDevice(
|
||||
connector.deviceDisplayName,
|
||||
)
|
||||
: context.l10n.appSettings_batteryChemistryConnectFirst,
|
||||
),
|
||||
trailing: DropdownButton<String>(
|
||||
value: selection,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
),
|
||||
|
||||
// Dropdown (separate full-width row)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: selection,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
border: UnderlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: isConnected
|
||||
? (value) {
|
||||
if (value != null) {
|
||||
settingsService.setBatteryChemistryForDevice(deviceId, value);
|
||||
settingsService.setBatteryChemistryForDevice(
|
||||
deviceId,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
@@ -391,7 +490,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showThemeModeDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -471,12 +573,19 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
return context.l10n.appSettings_languageSk;
|
||||
case 'bg':
|
||||
return context.l10n.appSettings_languageBg;
|
||||
case 'ru':
|
||||
return context.l10n.appSettings_languageRu;
|
||||
case 'uk':
|
||||
return context.l10n.appSettings_languageUk;
|
||||
default:
|
||||
return context.l10n.appSettings_languageSystem;
|
||||
}
|
||||
}
|
||||
|
||||
void _showLanguageDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showLanguageDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -547,6 +656,14 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
title: Text(context.l10n.appSettings_languageBg),
|
||||
value: 'bg',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageRu),
|
||||
value: 'ru',
|
||||
),
|
||||
RadioListTile<String?>(
|
||||
title: Text(context.l10n.appSettings_languageUk),
|
||||
value: 'uk',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -561,7 +678,10 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showTimeFilterDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -581,33 +701,23 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_allTime),
|
||||
leading: Radio<double>(
|
||||
value: 0,
|
||||
),
|
||||
leading: Radio<double>(value: 0),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_lastHour),
|
||||
leading: Radio<double>(
|
||||
value: 1,
|
||||
),
|
||||
leading: Radio<double>(value: 1),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_last6Hours),
|
||||
leading: Radio<double>(
|
||||
value: 6,
|
||||
),
|
||||
leading: Radio<double>(value: 6),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_last24Hours),
|
||||
leading: Radio<double>(
|
||||
value: 24,
|
||||
),
|
||||
leading: Radio<double>(value: 24),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_lastWeek),
|
||||
leading: Radio<double>(
|
||||
value: 168,
|
||||
),
|
||||
leading: Radio<double>(value: 168),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -622,7 +732,50 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) {
|
||||
void _showUnitsDialog(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.appSettings_unitsTitle),
|
||||
content: RadioGroup<UnitSystem>(
|
||||
groupValue: settingsService.settings.unitSystem,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
settingsService.setUnitSystem(value);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_unitsMetric),
|
||||
leading: const Radio<UnitSystem>(value: UnitSystem.metric),
|
||||
),
|
||||
ListTile(
|
||||
title: Text(context.l10n.appSettings_unitsImperial),
|
||||
leading: const Radio<UnitSystem>(value: UnitSystem.imperial),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDebugCard(
|
||||
BuildContext context,
|
||||
AppSettingsService settingsService,
|
||||
) {
|
||||
return Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -644,9 +797,11 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(value
|
||||
? context.l10n.appSettings_appDebugLoggingEnabled
|
||||
: context.l10n.appSettings_appDebugLoggingDisabled),
|
||||
content: Text(
|
||||
value
|
||||
? context.l10n.appSettings_appDebugLoggingEnabled
|
||||
: context.l10n.appSettings_appDebugLoggingDisabled,
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/services.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/ble_debug_log_service.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
|
||||
enum _BleLogView { frames, rawLogRx }
|
||||
|
||||
@@ -24,10 +25,12 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
final entries = logService.entries.reversed.toList();
|
||||
final rawEntries = logService.rawLogRxEntries.reversed.toList();
|
||||
final showingFrames = _view == _BleLogView.frames;
|
||||
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
|
||||
final hasEntries = showingFrames
|
||||
? entries.isNotEmpty
|
||||
: rawEntries.isNotEmpty;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.debugLog_bleTitle),
|
||||
title: AdaptiveAppBarTitle(context.l10n.debugLog_bleTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: context.l10n.debugLog_copyLog,
|
||||
@@ -36,15 +39,23 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
? () async {
|
||||
final text = showingFrames
|
||||
? entries
|
||||
.map((entry) => '${entry.description}\n${entry.hexPreview}\n')
|
||||
.join('\n')
|
||||
.map(
|
||||
(entry) =>
|
||||
'${entry.description}\n${entry.hexPreview}\n',
|
||||
)
|
||||
.join('\n')
|
||||
: rawEntries
|
||||
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n')
|
||||
.join('\n');
|
||||
.map(
|
||||
(entry) =>
|
||||
'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n',
|
||||
)
|
||||
.join('\n');
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.debugLog_bleCopied)),
|
||||
SnackBar(
|
||||
content: Text(context.l10n.debugLog_bleCopied),
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
@@ -68,8 +79,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: SegmentedButton<_BleLogView>(
|
||||
segments: [
|
||||
ButtonSegment(value: _BleLogView.frames, label: Text(context.l10n.debugLog_frames)),
|
||||
ButtonSegment(value: _BleLogView.rawLogRx, label: Text(context.l10n.debugLog_rawLogRx)),
|
||||
ButtonSegment(
|
||||
value: _BleLogView.frames,
|
||||
label: Text(context.l10n.debugLog_frames),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: _BleLogView.rawLogRx,
|
||||
label: Text(context.l10n.debugLog_rawLogRx),
|
||||
),
|
||||
],
|
||||
selected: {_view},
|
||||
onSelectionChanged: (selection) {
|
||||
@@ -81,8 +98,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
Expanded(
|
||||
child: hasEntries
|
||||
? ListView.separated(
|
||||
itemCount: showingFrames ? entries.length : rawEntries.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemCount: showingFrames
|
||||
? entries.length
|
||||
: rawEntries.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
if (showingFrames) {
|
||||
final entry = entries[index];
|
||||
@@ -94,9 +113,24 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
subtitle: Text('${entry.hexPreview}\n$time'),
|
||||
isThreeLine: true,
|
||||
leading: Icon(
|
||||
entry.outgoing ? Icons.upload : Icons.download,
|
||||
entry.outgoing
|
||||
? Icons.upload
|
||||
: Icons.download,
|
||||
size: 18,
|
||||
),
|
||||
onLongPress: () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: entry.payload
|
||||
.map(
|
||||
(b) => b
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0'),
|
||||
)
|
||||
.join(''),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,9 +165,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(info.title),
|
||||
content: SingleChildScrollView(
|
||||
child: SelectableText(info.rawHex),
|
||||
),
|
||||
content: SingleChildScrollView(child: SelectableText(info.rawHex)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
@@ -195,11 +227,18 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
}
|
||||
final payload = raw.sublist(index);
|
||||
|
||||
final title = 'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||
final title =
|
||||
'RX ${_payloadTypeLabel(payloadType)} • ${_routeLabel(routeType)} • v$payloadVer';
|
||||
final summary = _decodePayloadSummary(payloadType, payload);
|
||||
final pathSummary = pathLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none';
|
||||
final pathSummary = pathLen > 0
|
||||
? 'Path=${_bytesToHex(pathBytes)}'
|
||||
: 'Path=none';
|
||||
final detail = '$summary • $pathSummary • len=${raw.length}';
|
||||
return _RawPacketInfo(title: title, summary: detail, rawHex: _bytesToHex(raw));
|
||||
return _RawPacketInfo(
|
||||
title: title,
|
||||
summary: detail,
|
||||
rawHex: _bytesToHex(raw),
|
||||
);
|
||||
}
|
||||
|
||||
String _decodePayloadSummary(int payloadType, Uint8List payload) {
|
||||
@@ -245,7 +284,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
|
||||
return 'ADVERT (short)';
|
||||
}
|
||||
var offset = 0;
|
||||
final pubKey = _bytesToHex(payload.sublist(offset, offset + 32), spaced: false);
|
||||
final pubKey = _bytesToHex(
|
||||
payload.sublist(offset, offset + 32),
|
||||
spaced: false,
|
||||
);
|
||||
offset += 32;
|
||||
final timestamp = readUint32LE(payload, offset);
|
||||
offset += 4;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,22 +4,27 @@ import 'dart:typed_data';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../models/app_settings.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
|
||||
class ChannelMessagePathScreen extends StatelessWidget {
|
||||
final ChannelMessage message;
|
||||
|
||||
final bool channelMessage;
|
||||
const ChannelMessagePathScreen({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.channelMessage = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -27,8 +32,16 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final l10n = context.l10n;
|
||||
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
|
||||
final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
|
||||
final primaryPathTmp = _selectPrimaryPath(
|
||||
message.pathBytes,
|
||||
message.pathVariants,
|
||||
);
|
||||
|
||||
final primaryPath = !channelMessage && !message.isOutgoing
|
||||
? Uint8List.fromList(primaryPathTmp.reversed.toList())
|
||||
: primaryPathTmp;
|
||||
final contacts = connector.allContacts;
|
||||
final hops = _buildPathHops(primaryPath, contacts, l10n);
|
||||
final hasHopDetails = primaryPath.isNotEmpty;
|
||||
final observedLabel = _formatObservedHops(
|
||||
primaryPath.length,
|
||||
@@ -36,17 +49,32 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
l10n,
|
||||
);
|
||||
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.channelPath_title),
|
||||
title: AdaptiveAppBarTitle(l10n.channelPath_title),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.radar_outlined),
|
||||
tooltip: l10n.channelPath_viewMap,
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => PathTraceMapScreen(
|
||||
title: context.l10n.contacts_repeaterPathTrace,
|
||||
path: primaryPath,
|
||||
flipPathAround: true,
|
||||
reversePathAround:
|
||||
!(!channelMessage && !message.isOutgoing),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.map_outlined),
|
||||
tooltip: l10n.channelPath_viewMap,
|
||||
onPressed: hasHopDetails
|
||||
? () {
|
||||
_openPathMap(context);
|
||||
_openPathMap(context, channelMessage: channelMessage);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
@@ -88,10 +116,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(
|
||||
BuildContext context, {
|
||||
String? observedLabel,
|
||||
}) {
|
||||
Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
|
||||
final l10n = context.l10n;
|
||||
return Card(
|
||||
child: Padding(
|
||||
@@ -105,21 +130,28 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
|
||||
_buildDetailRow(l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n)),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_timeLabel,
|
||||
_formatTime(message.timestamp, l10n),
|
||||
),
|
||||
if (message.repeatCount > 0)
|
||||
_buildDetailRow(l10n.channelPath_repeatsLabel, message.repeatCount.toString()),
|
||||
_buildDetailRow(l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n)),
|
||||
if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_repeatsLabel,
|
||||
message.repeatCount.toString(),
|
||||
),
|
||||
_buildDetailRow(
|
||||
l10n.channelPath_pathLabelTitle,
|
||||
_formatPathLabel(message.pathLength, l10n),
|
||||
),
|
||||
if (observedLabel != null)
|
||||
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPathVariants(
|
||||
BuildContext context,
|
||||
List<Uint8List> variants,
|
||||
) {
|
||||
Widget _buildPathVariants(BuildContext context, List<Uint8List> variants) {
|
||||
final l10n = context.l10n;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -137,7 +169,11 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
),
|
||||
subtitle: Text(_formatPathPrefixes(variants[i])),
|
||||
trailing: const Icon(Icons.map_outlined, size: 20),
|
||||
onTap: () => _openPathMap(context, initialPath: variants[i]),
|
||||
onTap: () => _openPathMap(
|
||||
context,
|
||||
initialPath: variants[i],
|
||||
channelMessage: channelMessage,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -163,7 +199,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
subtitle: Text(
|
||||
hop.hasLocation
|
||||
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: l10n.channelPath_noLocationData,
|
||||
),
|
||||
),
|
||||
@@ -228,28 +264,34 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _openPathMap(BuildContext context, {Uint8List? initialPath}) {
|
||||
void _openPathMap(
|
||||
BuildContext context, {
|
||||
Uint8List? initialPath,
|
||||
bool channelMessage = false,
|
||||
}) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelMessagePathMapScreen(
|
||||
message: message,
|
||||
initialPath: initialPath,
|
||||
channelMessage: channelMessage,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ChannelMessagePathMapScreen extends StatefulWidget {
|
||||
final ChannelMessage message;
|
||||
final Uint8List? initialPath;
|
||||
final bool channelMessage;
|
||||
|
||||
const ChannelMessagePathMapScreen({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.initialPath,
|
||||
this.channelMessage = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -257,8 +299,14 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
|
||||
_ChannelMessagePathMapScreenState();
|
||||
}
|
||||
|
||||
class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScreen> {
|
||||
class _ChannelMessagePathMapScreenState
|
||||
extends State<ChannelMessagePathMapScreen> {
|
||||
static const double _labelZoomThreshold = 8.5;
|
||||
|
||||
Uint8List? _selectedPath;
|
||||
double _pathDistance = 0.0;
|
||||
bool _showNodeLabels = true;
|
||||
bool _didReceivePositionUpdate = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -270,32 +318,74 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.message != widget.message ||
|
||||
!_pathsEqual(oldWidget.initialPath ?? Uint8List(0),
|
||||
widget.initialPath ?? Uint8List(0))) {
|
||||
!_pathsEqual(
|
||||
oldWidget.initialPath ?? Uint8List(0),
|
||||
widget.initialPath ?? Uint8List(0),
|
||||
)) {
|
||||
_selectedPath = widget.initialPath;
|
||||
}
|
||||
}
|
||||
|
||||
double _getPathDistance(List<LatLng> points) {
|
||||
double totalDistance = 0.0;
|
||||
final distanceCalculator = Distance();
|
||||
|
||||
for (int i = 0; i < points.length - 1; i++) {
|
||||
totalDistance += distanceCalculator(points[i], points[i + 1]);
|
||||
}
|
||||
|
||||
return totalDistance;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final settings = context.watch<AppSettingsService>().settings;
|
||||
final isImperial = settings.unitSystem == UnitSystem.imperial;
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final primaryPath =
|
||||
_selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants);
|
||||
final observedPaths =
|
||||
_buildObservedPaths(primaryPath, widget.message.pathVariants);
|
||||
final selectedPath = _resolveSelectedPath(
|
||||
final primaryPath = _selectPrimaryPath(
|
||||
widget.message.pathBytes,
|
||||
widget.message.pathVariants,
|
||||
);
|
||||
final observedPaths = _buildObservedPaths(
|
||||
primaryPath,
|
||||
widget.message.pathVariants,
|
||||
);
|
||||
final selectedPathTmp = _resolveSelectedPath(
|
||||
_selectedPath,
|
||||
observedPaths,
|
||||
primaryPath,
|
||||
);
|
||||
|
||||
final selectedPath =
|
||||
((!widget.message.isOutgoing && !widget.channelMessage) ||
|
||||
(widget.message.isOutgoing && widget.channelMessage))
|
||||
? Uint8List.fromList(selectedPathTmp.reversed.toList())
|
||||
: selectedPathTmp;
|
||||
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n);
|
||||
final points = hops
|
||||
.where((hop) => hop.hasLocation)
|
||||
.map((hop) => hop.position!)
|
||||
.toList();
|
||||
final contacts = connector.allContacts;
|
||||
final hops = _buildPathHops(selectedPath, contacts, context.l10n);
|
||||
|
||||
final points = <LatLng>[];
|
||||
|
||||
if ((widget.message.isOutgoing && !widget.channelMessage) ||
|
||||
(widget.message.isOutgoing && widget.channelMessage)) {
|
||||
points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||
}
|
||||
|
||||
for (final hop in hops) {
|
||||
if (hop.hasLocation) {
|
||||
points.add(hop.position!);
|
||||
}
|
||||
}
|
||||
|
||||
if ((!widget.message.isOutgoing && !widget.channelMessage) ||
|
||||
(!widget.message.isOutgoing && widget.channelMessage)) {
|
||||
points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||
}
|
||||
|
||||
final polylines = points.length > 1
|
||||
? [
|
||||
Polyline(
|
||||
@@ -306,15 +396,24 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
]
|
||||
: <Polyline>[];
|
||||
|
||||
final initialCenter =
|
||||
points.isNotEmpty ? points.first : const LatLng(0, 0);
|
||||
final initialCenter = points.isNotEmpty
|
||||
? points.first
|
||||
: const LatLng(0, 0);
|
||||
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
|
||||
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null;
|
||||
final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
|
||||
if (!_didReceivePositionUpdate) {
|
||||
_showNodeLabels = initialZoom >= _labelZoomThreshold;
|
||||
}
|
||||
final bounds = points.length > 1
|
||||
? LatLngBounds.fromPoints(points)
|
||||
: null;
|
||||
final mapKey = ValueKey(
|
||||
'${_formatPathPrefixes(selectedPath)},${context.l10n.pathTrace_you}',
|
||||
);
|
||||
_pathDistance = _getPathDistance(points);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.channelPath_mapTitle),
|
||||
title: AdaptiveAppBarTitle(context.l10n.channelPath_mapTitle),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
@@ -334,6 +433,20 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
),
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
interactionOptions: InteractionOptions(
|
||||
flags: ~InteractiveFlag.rotate,
|
||||
),
|
||||
onPositionChanged: (camera, hasGesture) {
|
||||
final shouldShow = camera.zoom >= _labelZoomThreshold;
|
||||
if (!_didReceivePositionUpdate ||
|
||||
shouldShow != _showNodeLabels) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_didReceivePositionUpdate = true;
|
||||
_showNodeLabels = shouldShow;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
@@ -343,34 +456,37 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
|
||||
if (polylines.isNotEmpty)
|
||||
PolylineLayer(polylines: polylines),
|
||||
MarkerLayer(
|
||||
markers: _buildHopMarkers(hops),
|
||||
markers: _buildHopMarkers(
|
||||
hops,
|
||||
showLabels: _showNodeLabels,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (observedPaths.length > 1)
|
||||
_buildPathSelector(
|
||||
context,
|
||||
observedPaths,
|
||||
selectedIndex,
|
||||
(index) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildPathSelector(context, observedPaths, selectedIndex, (
|
||||
index,
|
||||
) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
});
|
||||
}),
|
||||
if (points.isEmpty)
|
||||
Center(
|
||||
child: Card(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(context.l10n.channelPath_noRepeaterLocations),
|
||||
child: Text(
|
||||
context.l10n.channelPath_noRepeaterLocations,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildLegendCard(context, hops),
|
||||
_buildLegendCard(context, hops, isImperial),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -442,42 +558,141 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
);
|
||||
}
|
||||
|
||||
List<Marker> _buildHopMarkers(List<_PathHop> hops) {
|
||||
return [
|
||||
for (final hop in hops)
|
||||
if (hop.hasLocation)
|
||||
Marker(
|
||||
point: hop.position!,
|
||||
width: 40,
|
||||
height: 40,
|
||||
List<Marker> _buildHopMarkers(
|
||||
List<_PathHop> hops, {
|
||||
required bool showLabels,
|
||||
}) {
|
||||
final markers = <Marker>[];
|
||||
for (final hop in hops) {
|
||||
if (!hop.hasLocation) continue;
|
||||
final point = hop.position!;
|
||||
markers.add(
|
||||
Marker(
|
||||
point: point,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
hop.index.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (showLabels) {
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: point,
|
||||
label: hop.contact?.name ?? _formatPrefix(hop.prefix),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
|
||||
final selfLon = context.read<MeshCoreConnector>().selfLongitude;
|
||||
if (selfLat != null && selfLon != null) {
|
||||
final selfPoint = LatLng(selfLat, selfLon);
|
||||
markers.add(
|
||||
Marker(
|
||||
point: selfPoint,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.teal,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
context.l10n.pathTrace_you,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (showLabels) {
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: selfPoint,
|
||||
label: context.l10n.pathTrace_you,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
Marker _buildNodeLabelMarker({required LatLng point, required String label}) {
|
||||
return Marker(
|
||||
point: point,
|
||||
width: 120,
|
||||
height: 24,
|
||||
alignment: Alignment.topCenter,
|
||||
child: IgnorePointer(
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, -20),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green,
|
||||
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),
|
||||
),
|
||||
],
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
hop.index.toString(),
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegendCard(BuildContext context, List<_PathHop> hops) {
|
||||
Widget _buildLegendCard(
|
||||
BuildContext context,
|
||||
List<_PathHop> hops,
|
||||
bool isImperial,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.35;
|
||||
final estimatedHeight = 72.0 + (hops.length * 56.0);
|
||||
@@ -496,7 +711,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
l10n.channelPath_repeaterHops,
|
||||
'${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistance, isImperial: isImperial)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
@@ -509,7 +724,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: hops.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final hop = hops[index];
|
||||
return ListTile(
|
||||
@@ -525,7 +740,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
|
||||
subtitle: Text(
|
||||
hop.hasLocation
|
||||
? '${hop.position!.latitude.toStringAsFixed(5)}, '
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
'${hop.position!.longitude.toStringAsFixed(5)}'
|
||||
: l10n.channelPath_noLocationData,
|
||||
),
|
||||
);
|
||||
@@ -567,10 +782,7 @@ class _ObservedPath {
|
||||
final Uint8List pathBytes;
|
||||
final bool isPrimary;
|
||||
|
||||
const _ObservedPath({
|
||||
required this.pathBytes,
|
||||
required this.isPrimary,
|
||||
});
|
||||
const _ObservedPath({required this.pathBytes, required this.isPrimary});
|
||||
}
|
||||
|
||||
List<_PathHop> _buildPathHops(
|
||||
@@ -597,10 +809,12 @@ List<_PathHop> _buildPathHops(
|
||||
|
||||
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
|
||||
final matches = contacts
|
||||
.where((contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix)
|
||||
.where(
|
||||
(contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix,
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return null;
|
||||
|
||||
|
||||
+1328
-300
File diff suppressed because it is too large
Load Diff
+1101
-473
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
class ChromeRequiredScreen extends StatelessWidget {
|
||||
const ChromeRequiredScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: isDark
|
||||
? [const Color(0xFF1A1A1A), const Color(0xFF0D0D0D)]
|
||||
: [const Color(0xFFF5F7FA), const Color(0xFFE4E7EB)],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.browser_not_supported_rounded,
|
||||
size: 80,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
l10n.scanner_chromeRequired,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
l10n.scanner_chromeRequiredMessage,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: isDark ? Colors.white70 : Colors.black54,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
// We can't really "fix" it for them other than telling them to use Chrome
|
||||
// but we can provide a nice visual.
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 20, color: Colors.blue),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
"Web Bluetooth requires a Chromium browser",
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/community.dart';
|
||||
import '../storage/community_store.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
import '../widgets/qr_scanner_widget.dart';
|
||||
|
||||
/// Screen for scanning community QR codes to join communities.
|
||||
///
|
||||
/// After successful scan, the user can:
|
||||
/// 1. Join the community (saves to local storage)
|
||||
/// 2. Optionally add the Community Public Channel to the device
|
||||
class CommunityQrScannerScreen extends StatefulWidget {
|
||||
const CommunityQrScannerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CommunityQrScannerScreen> createState() =>
|
||||
_CommunityQrScannerScreenState();
|
||||
}
|
||||
|
||||
class _CommunityQrScannerScreenState extends State<CommunityQrScannerScreen> {
|
||||
final CommunityStore _communityStore = CommunityStore();
|
||||
bool _isProcessing = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AdaptiveAppBarTitle(context.l10n.community_scanQr),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: _isProcessing
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: QrScannerWidget(
|
||||
onScanned: (data) => _handleScannedData(context, data),
|
||||
validator: Community.isValidQrData,
|
||||
onValidationFailed: (_) => _showInvalidQrError(context),
|
||||
instructions: context.l10n.community_scanInstructions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleScannedData(BuildContext context, String data) async {
|
||||
if (_isProcessing) return;
|
||||
|
||||
setState(() {
|
||||
_isProcessing = true;
|
||||
});
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||
|
||||
try {
|
||||
// Parse the community data
|
||||
final community = Community.fromQrData(const Uuid().v4(), data);
|
||||
|
||||
// Check if this community already exists
|
||||
final existing = await _communityStore.findByCommunityId(
|
||||
community.communityId,
|
||||
);
|
||||
|
||||
if (existing != null) {
|
||||
if (context.mounted) {
|
||||
_showAlreadyMemberDialog(context, existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
if (context.mounted) {
|
||||
await _showJoinConfirmationDialog(context, community);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.community_invalidQrCode),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showInvalidQrError(BuildContext context) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.community_invalidQrCode),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAlreadyMemberDialog(BuildContext context, Community community) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(context.l10n.community_alreadyMember),
|
||||
content: Text(
|
||||
context.l10n.community_alreadyMemberMessage(community.name),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text(context.l10n.common_ok),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showJoinConfirmationDialog(
|
||||
BuildContext context,
|
||||
Community community,
|
||||
) async {
|
||||
bool addPublicChannel = true;
|
||||
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) => AlertDialog(
|
||||
title: Text(context.l10n.community_joinTitle),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.community_joinConfirmation(community.name)),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.groups,
|
||||
color: Theme.of(dialogContext).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
community.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'ID: ${community.shortCommunityId}...',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
CheckboxListTile(
|
||||
value: addPublicChannel,
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
addPublicChannel = value ?? true;
|
||||
});
|
||||
},
|
||||
title: Text(context.l10n.community_addPublicChannel),
|
||||
subtitle: Text(context.l10n.community_addPublicChannelHint),
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: Text(context.l10n.common_cancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: Text(context.l10n.community_join),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true && context.mounted) {
|
||||
await _joinCommunity(context, community, addPublicChannel);
|
||||
} else if (context.mounted) {
|
||||
// User cancelled - go back
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _joinCommunity(
|
||||
BuildContext context,
|
||||
Community community,
|
||||
bool addPublicChannel,
|
||||
) async {
|
||||
// Save community to local storage
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||
await _communityStore.addCommunity(community);
|
||||
|
||||
// Optionally add the community public channel to the device
|
||||
if (addPublicChannel && context.mounted) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final nextIndex = _findNextAvailableChannelIndex(connector);
|
||||
|
||||
if (nextIndex != null) {
|
||||
final psk = community.deriveCommunityPublicPsk();
|
||||
final channelName = '${community.name} Public';
|
||||
connector.setChannel(nextIndex, channelName, psk);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.community_joined(community.name)),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
||||
// Return to previous screen
|
||||
Navigator.pop(context, community);
|
||||
}
|
||||
}
|
||||
|
||||
int? _findNextAvailableChannelIndex(MeshCoreConnector connector) {
|
||||
final usedIndices = connector.channels.map((c) => c.index).toSet();
|
||||
for (int i = 0; i < connector.maxChannels; i++) {
|
||||
if (!usedIndices.contains(i)) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+1038
-315
File diff suppressed because it is too large
Load Diff
@@ -127,9 +127,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -207,7 +205,6 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildBatteryIndicator(
|
||||
MeshCoreConnector connector,
|
||||
BuildContext context,
|
||||
@@ -224,11 +221,7 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
final icon = _batteryIcon(percent);
|
||||
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
avatar: Icon(icon, size: 16, color: colorScheme.onSecondaryContainer),
|
||||
label: Text(displayLabel),
|
||||
labelStyle: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
@@ -260,25 +253,19 @@ class _DeviceScreenState extends State<DeviceScreen>
|
||||
case 0:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ContactsScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ChannelsScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const MapScreen(hideBackButton: true),
|
||||
),
|
||||
buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../widgets/app_bar.dart';
|
||||
import '../widgets/list_filter_widget.dart';
|
||||
|
||||
enum DiscoverySortOption { lastSeen, name, type }
|
||||
|
||||
class DiscoveryScreen extends StatefulWidget {
|
||||
const DiscoveryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
|
||||
}
|
||||
|
||||
class _DiscoveryScreenState extends State<DiscoveryScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String searchQuery = '';
|
||||
ContactSortOption sortOption = ContactSortOption.lastSeen;
|
||||
bool showUnreadOnly = false;
|
||||
ContactTypeFilter typeFilter = ContactTypeFilter.all;
|
||||
DiscoverySortOption discoverySortOption = DiscoverySortOption.lastSeen;
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_searchDebounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
|
||||
final discoveredContacts = connector.discoveredContacts;
|
||||
final filteredAndSorted = _filterAndSortContacts(
|
||||
discoveredContacts,
|
||||
connector,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: AppBarTitle(
|
||||
l10n.discoveredContacts_Title,
|
||||
indicators: false,
|
||||
subtitle: false,
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.delete, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(context.l10n.discoveredContacts_deleteContactAll),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_deleteContacts(context, connector);
|
||||
},
|
||||
),
|
||||
],
|
||||
icon: const Icon(Icons.more_vert),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildFilters(filteredAndSorted, connector),
|
||||
Expanded(
|
||||
child: discoveredContacts.isEmpty
|
||||
? Center(child: Text(l10n.contacts_noContacts))
|
||||
: filteredAndSorted.isEmpty
|
||||
? Center(child: Text(l10n.discoveredContacts_noMatching))
|
||||
: ListView.builder(
|
||||
itemCount: filteredAndSorted.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = filteredAndSorted[index];
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: _getTypeColor(contact.type),
|
||||
child: Icon(
|
||||
_getTypeIcon(contact.type),
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
contact.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
contact.shortPubKeyHex,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: Text(
|
||||
_formatLastSeen(context, contact.lastSeen),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
connector.importDiscoveredContact(contact);
|
||||
},
|
||||
onLongPress: () =>
|
||||
_showContactContextMenu(contact, connector),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showContactContextMenu(
|
||||
Contact contact,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
final action = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) {
|
||||
final l10n = context.l10n;
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_reaction_sharp),
|
||||
title: Text(l10n.discoveredContacts_addContact),
|
||||
onTap: () => Navigator.of(sheetContext).pop('import_contact'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: Text(l10n.discoveredContacts_copyContact),
|
||||
onTap: () => Navigator.of(sheetContext).pop('copy_contact'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete),
|
||||
title: Text(l10n.discoveredContacts_deleteContact),
|
||||
onTap: () => Navigator.of(sheetContext).pop('delete_contact'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!mounted || action == null) return;
|
||||
|
||||
switch (action) {
|
||||
case 'import_contact':
|
||||
connector.importDiscoveredContact(contact);
|
||||
break;
|
||||
case 'copy_contact':
|
||||
if (contact.rawPacket == null) return;
|
||||
final hexString = pubKeyToHex(contact.rawPacket!);
|
||||
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)),
|
||||
);
|
||||
break;
|
||||
case 'delete_contact':
|
||||
connector.removeDiscoveredContact(contact);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _deleteContacts(BuildContext context, MeshCoreConnector connector) {
|
||||
final l10n = context.l10n;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.common_deleteAll),
|
||||
content: Text(l10n.discoveredContacts_deleteContactAllContent),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.common_cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
connector.removeAllDiscoveredContacts();
|
||||
},
|
||||
child: Text(l10n.common_deleteAll),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilters(
|
||||
List<Contact> filteredAndSorted,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
String hintText = "";
|
||||
switch (typeFilter) {
|
||||
case ContactTypeFilter.all:
|
||||
hintText = context.l10n.contacts_searchContacts(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.users:
|
||||
hintText = context.l10n.contacts_searchUsers(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.repeaters:
|
||||
hintText = context.l10n.contacts_searchRepeaters(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.rooms:
|
||||
hintText = context.l10n.contacts_searchRoomServers(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
case ContactTypeFilter.favorites:
|
||||
hintText = context.l10n.contacts_searchFavorites(
|
||||
filteredAndSorted.length,
|
||||
showUnreadOnly ? " ${context.l10n.contacts_unread}" : "",
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (searchQuery.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
searchQuery = '';
|
||||
});
|
||||
},
|
||||
),
|
||||
_buildFilterButton(context, connector),
|
||||
],
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
searchQuery = value.toLowerCase();
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterButton(BuildContext context, MeshCoreConnector connector) {
|
||||
return DiscoveryContactsFilterMenu(
|
||||
sortOption: sortOption,
|
||||
typeFilter: typeFilter,
|
||||
onSortChanged: (value) {
|
||||
setState(() {
|
||||
sortOption = value;
|
||||
});
|
||||
},
|
||||
onTypeFilterChanged: (value) {
|
||||
setState(() {
|
||||
typeFilter = value;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Contact> _filterAndSortContacts(
|
||||
List<Contact> contacts,
|
||||
MeshCoreConnector connector,
|
||||
) {
|
||||
var filtered = contacts.where((contact) {
|
||||
if (searchQuery.isEmpty) return true;
|
||||
return matchesDiscoveryContactQuery(contact, searchQuery);
|
||||
}).toList();
|
||||
|
||||
filtered = filtered.where((contact) {
|
||||
return !connector.knownContactKeys.contains(contact.publicKeyHex);
|
||||
}).toList();
|
||||
|
||||
// Filter out own node from the list
|
||||
if (connector.selfPublicKey != null) {
|
||||
final selfPubKeyHex = pubKeyToHex(connector.selfPublicKey!);
|
||||
filtered = filtered.where((contact) {
|
||||
return contact.publicKeyHex != selfPubKeyHex;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
if (typeFilter != ContactTypeFilter.all) {
|
||||
filtered = filtered.where(_matchesTypeFilter).toList();
|
||||
}
|
||||
|
||||
switch (sortOption) {
|
||||
case ContactSortOption.lastSeen:
|
||||
filtered.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||
break;
|
||||
case ContactSortOption.name:
|
||||
filtered.sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
bool _matchesTypeFilter(Contact contact) {
|
||||
switch (typeFilter) {
|
||||
case ContactTypeFilter.all:
|
||||
return true;
|
||||
case ContactTypeFilter.users:
|
||||
return contact.type == advTypeChat;
|
||||
case ContactTypeFilter.repeaters:
|
||||
return contact.type == advTypeRepeater;
|
||||
case ContactTypeFilter.rooms:
|
||||
return contact.type == advTypeRoom;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getTypeIcon(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Icons.chat;
|
||||
case advTypeRepeater:
|
||||
return Icons.cell_tower;
|
||||
case advTypeRoom:
|
||||
return Icons.group;
|
||||
case advTypeSensor:
|
||||
return Icons.sensors;
|
||||
default:
|
||||
return Icons.device_unknown;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getTypeColor(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Colors.blue;
|
||||
case advTypeRepeater:
|
||||
return Colors.orange;
|
||||
case advTypeRoom:
|
||||
return Colors.purple;
|
||||
case advTypeSensor:
|
||||
return Colors.green;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
String _formatLastSeen(BuildContext context, DateTime lastSeen) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastSeen);
|
||||
|
||||
if (diff.isNegative || diff.inMinutes < 5) {
|
||||
return context.l10n.contacts_lastSeenNow;
|
||||
}
|
||||
if (diff.inMinutes < 60) {
|
||||
return context.l10n.contacts_lastSeenMinsAgo(diff.inMinutes);
|
||||
}
|
||||
if (diff.inHours < 24) {
|
||||
final hours = diff.inHours;
|
||||
return hours == 1
|
||||
? context.l10n.contacts_lastSeenHourAgo
|
||||
: context.l10n.contacts_lastSeenHoursAgo(hours);
|
||||
}
|
||||
final days = diff.inDays;
|
||||
return days == 1
|
||||
? context.l10n.contacts_lastSeenDayAgo
|
||||
: context.l10n.contacts_lastSeenDaysAgo(days);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import '../l10n/app_localizations.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
import '../widgets/adaptive_app_bar_title.dart';
|
||||
|
||||
class MapCacheScreen extends StatefulWidget {
|
||||
const MapCacheScreen({super.key});
|
||||
@@ -56,10 +57,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
_updateEstimate();
|
||||
if (bounds != null) {
|
||||
_mapController.fitCamera(
|
||||
CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(48),
|
||||
),
|
||||
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -72,8 +70,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
return;
|
||||
}
|
||||
final cacheService = context.read<MapTileCacheService>();
|
||||
final count =
|
||||
cacheService.estimateTileCount(_selectedBounds!, _minZoom, _maxZoom);
|
||||
final count = cacheService.estimateTileCount(
|
||||
_selectedBounds!,
|
||||
_minZoom,
|
||||
_maxZoom,
|
||||
);
|
||||
setState(() {
|
||||
_estimatedTiles = count;
|
||||
});
|
||||
@@ -181,9 +182,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
result.failed,
|
||||
)
|
||||
: context.l10n.mapCache_cachedTiles(result.downloaded);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Future<void> _clearCache() async {
|
||||
@@ -225,7 +226,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.mapCache_title),
|
||||
title: AdaptiveAppBarTitle(l10n.mapCache_title),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Column(
|
||||
@@ -290,7 +291,10 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
children: [
|
||||
Text(
|
||||
l10n.mapCache_cacheArea,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
@@ -304,8 +308,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed:
|
||||
_isDownloading || selectedBounds == null ? null : _clearBounds,
|
||||
onPressed: _isDownloading || selectedBounds == null
|
||||
? null
|
||||
: _clearBounds,
|
||||
child: Text(l10n.common_clear),
|
||||
),
|
||||
],
|
||||
@@ -313,11 +318,16 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.mapCache_zoomRange,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
RangeSlider(
|
||||
values:
|
||||
RangeValues(_minZoom.toDouble(), _maxZoom.toDouble()),
|
||||
values: RangeValues(
|
||||
_minZoom.toDouble(),
|
||||
_maxZoom.toDouble(),
|
||||
),
|
||||
min: 3,
|
||||
max: 18,
|
||||
divisions: 15,
|
||||
@@ -341,10 +351,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: progressValue),
|
||||
const SizedBox(height: 4),
|
||||
Text(l10n.mapCache_downloadedTiles(
|
||||
_completedTiles,
|
||||
_estimatedTiles,
|
||||
)),
|
||||
Text(
|
||||
l10n.mapCache_downloadedTiles(
|
||||
_completedTiles,
|
||||
_estimatedTiles,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
|
||||
+1153
-270
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/utils/app_logger.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/path_selection.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../services/repeater_command_service.dart';
|
||||
import '../widgets/path_management_dialog.dart';
|
||||
import '../widgets/snr_indicator.dart';
|
||||
|
||||
class NeighborsScreen extends StatefulWidget {
|
||||
final Contact repeater;
|
||||
final String password;
|
||||
|
||||
const NeighborsScreen({
|
||||
super.key,
|
||||
required this.repeater,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
@override
|
||||
State<NeighborsScreen> createState() => _NeighborsScreenState();
|
||||
}
|
||||
|
||||
class _NeighborsScreenState extends State<NeighborsScreen> {
|
||||
static const int _reqNeighborsKeyLen = 4;
|
||||
static const int _statusPayloadOffset = 8;
|
||||
static const int _statusStatsSize = 52;
|
||||
static const int _statusResponseBytes =
|
||||
_statusPayloadOffset + _statusStatsSize;
|
||||
Uint8List _tagData = Uint8List(4);
|
||||
int _neighborCount = 0;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _isLoaded = false;
|
||||
bool _hasData = false;
|
||||
Timer? _statusTimeout;
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
RepeaterCommandService? _commandService;
|
||||
PathSelection? _pendingStatusSelection;
|
||||
List<Map<String, dynamic>>? _parsedNeighbors;
|
||||
|
||||
int _resolveRepeaterIndex = -1;
|
||||
|
||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||
if (_resolveRepeaterIndex >= 0 &&
|
||||
_resolveRepeaterIndex < connector.contacts.length &&
|
||||
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||
widget.repeater.publicKeyHex) {
|
||||
return connector.contacts[_resolveRepeaterIndex];
|
||||
}
|
||||
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||
);
|
||||
if (_resolveRepeaterIndex == -1) {
|
||||
return widget.repeater;
|
||||
}
|
||||
return connector.contacts[_resolveRepeaterIndex];
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
_commandService = RepeaterCommandService(connector);
|
||||
_setupMessageListener();
|
||||
_loadNeighbors();
|
||||
_hasData = false;
|
||||
}
|
||||
|
||||
void _setupMessageListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
|
||||
if (frame[0] == respCodeSent) {
|
||||
_tagData = frame.sublist(2, 6);
|
||||
}
|
||||
|
||||
// Check if it's a binary response
|
||||
if (frame[0] == pushCodeBinaryResponse &&
|
||||
listEquals(frame.sublist(2, 6), _tagData)) {
|
||||
_handleNeighborsResponse(connector, frame.sublist(6));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String fmtDuration(double seconds) {
|
||||
if (seconds < 60) {
|
||||
return '${seconds.toStringAsFixed(1)}s';
|
||||
}
|
||||
|
||||
final int m = (seconds ~/ 60).toInt();
|
||||
final double s = seconds - (60 * m);
|
||||
|
||||
if (m < 60) {
|
||||
return '${m}m ${s.toStringAsFixed(0)}s';
|
||||
}
|
||||
|
||||
final int h = m ~/ 60;
|
||||
final int m2 = m % 60;
|
||||
|
||||
return '${h}h ${m2}m';
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> parseNeighborsData(
|
||||
BufferReader buffer,
|
||||
int resultsCount,
|
||||
) {
|
||||
final Map<int, Map<String, dynamic>> neighbors = {};
|
||||
try {
|
||||
for (var i = 0; i < resultsCount; i++) {
|
||||
final neighborData = neighbors.putIfAbsent(
|
||||
i,
|
||||
() => {
|
||||
'contact': null,
|
||||
'publicKey': <Uint8List>{},
|
||||
'lastHeard': <int>{},
|
||||
'snr': <double>{},
|
||||
},
|
||||
);
|
||||
neighborData['publicKey'] = buffer.readBytes(_reqNeighborsKeyLen);
|
||||
neighborData['lastHeard'] = buffer.readUInt32LE();
|
||||
neighborData['snr'] = buffer.readInt8() / 4.0;
|
||||
}
|
||||
|
||||
return neighbors.values.toList();
|
||||
} catch (e) {
|
||||
appLogger.error(
|
||||
'Error parsing neighbors data: $e',
|
||||
tag: 'NeighborsScreen',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
void _handleNeighborsResponse(MeshCoreConnector connector, Uint8List frame) {
|
||||
final buffer = BufferReader(frame);
|
||||
final contacts = connector.allContacts;
|
||||
try {
|
||||
final neighborCount = buffer.readUInt16LE();
|
||||
final parsedNeighbors = parseNeighborsData(buffer, buffer.readUInt16LE());
|
||||
contacts.where((c) => c.type == advTypeRepeater).forEach((repeater) {
|
||||
for (var neighborData in parsedNeighbors) {
|
||||
final publicKey = neighborData['publicKey'];
|
||||
if (listEquals(
|
||||
repeater.publicKey.sublist(0, _reqNeighborsKeyLen),
|
||||
publicKey,
|
||||
)) {
|
||||
neighborData['contact'] = repeater;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_parsedNeighbors = parsedNeighbors;
|
||||
_neighborCount = neighborCount;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.neighbors_receivedData),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
_statusTimeout?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = true;
|
||||
_hasData = true;
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.error('Error handling neighbors response: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadNeighbors() async {
|
||||
if (_commandService == null) return;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_isLoaded = false;
|
||||
});
|
||||
try {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final selection = await connector.preparePathForContactSend(repeater);
|
||||
_pendingStatusSelection = selection;
|
||||
|
||||
//[version][number of requested neighbors][offset_16bit][order by][len of public key]
|
||||
final frame = buildSendBinaryReq(
|
||||
repeater.publicKey,
|
||||
payload: Uint8List.fromList([
|
||||
reqTypeGetNeighbors,
|
||||
0x00,
|
||||
0x0F,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
_reqNeighborsKeyLen,
|
||||
]),
|
||||
);
|
||||
await connector.sendFrame(frame);
|
||||
|
||||
final pathLengthValue = selection.useFlood ? -1 : selection.hopCount;
|
||||
final messageBytes = frame.length >= _statusResponseBytes
|
||||
? frame.length
|
||||
: _statusResponseBytes;
|
||||
final timeoutMs = connector.calculateTimeout(
|
||||
pathLength: pathLengthValue,
|
||||
messageBytes: messageBytes,
|
||||
);
|
||||
_statusTimeout?.cancel();
|
||||
_statusTimeout = Timer(Duration(milliseconds: timeoutMs), () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = false;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.neighbors_requestTimedOut),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
_recordStatusResult(false);
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isLoaded = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n.neighbors_errorLoading(e.toString())),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _recordStatusResult(bool success) {
|
||||
final selection = _pendingStatusSelection;
|
||||
if (selection == null) return;
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
connector.recordRepeaterPathResult(repeater, selection, success, null);
|
||||
_pendingStatusSelection = null;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_commandService?.dispose();
|
||||
_statusTimeout?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final isFloodMode = repeater.pathOverride == -1;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
l10n.neighbors_repeatersNeighbors,
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
PopupMenuButton<String>(
|
||||
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
|
||||
tooltip: l10n.repeater_routingMode,
|
||||
onSelected: (mode) async {
|
||||
if (mode == 'flood') {
|
||||
await connector.setPathOverride(repeater, pathLen: -1);
|
||||
} else {
|
||||
await connector.setPathOverride(repeater, pathLen: null);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: l10n.repeater_pathManagement,
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _loadNeighbors,
|
||||
tooltip: l10n.repeater_refresh,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _loadNeighbors,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (!_isLoaded &&
|
||||
!_hasData &&
|
||||
(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
|
||||
Center(
|
||||
child: Text(
|
||||
l10n.neighbors_noData,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
if (_isLoaded ||
|
||||
_hasData &&
|
||||
!(_parsedNeighbors == null || _parsedNeighbors!.isEmpty))
|
||||
_buildNeighborsInfoCard(
|
||||
"${l10n.repeater_neighbors} - $_neighborCount",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNeighborsInfoCard(String title) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Theme.of(context).textTheme.headlineSmall?.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
for (final entry in _parsedNeighbors!.asMap().entries)
|
||||
_buildInfoRow(
|
||||
entry.value['contact'] != null
|
||||
? entry.value['contact'].name
|
||||
: context.l10n.neighbors_unknownContact(
|
||||
"<${pubKeyToHex(entry.value['publicKey'])}>",
|
||||
),
|
||||
context.l10n.neighbors_heardAgo(
|
||||
fmtDuration(entry.value['lastHeard'] + 0.0),
|
||||
),
|
||||
entry.value['snr'],
|
||||
connector.currentSf!,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(
|
||||
String label,
|
||||
String value,
|
||||
double snr,
|
||||
int spreadingFactor,
|
||||
) {
|
||||
final snrUi = snrUiFromSNR(snr, spreadingFactor);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(
|
||||
label,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(value),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(snrUi.icon, color: snrUi.color, size: 18.0),
|
||||
Text(
|
||||
snrUi.text,
|
||||
style: TextStyle(fontSize: 10, color: snrUi.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:meshcore_open/l10n/l10n.dart';
|
||||
import 'package:meshcore_open/models/app_settings.dart';
|
||||
import 'package:meshcore_open/models/contact.dart';
|
||||
import 'package:meshcore_open/services/app_settings_service.dart';
|
||||
import 'package:meshcore_open/services/map_tile_cache_service.dart';
|
||||
import 'package:meshcore_open/utils/app_logger.dart';
|
||||
import 'package:meshcore_open/widgets/snr_indicator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
double getPathDistanceMeters(List<LatLng> points) {
|
||||
if (points.length <= 1) return 0.0;
|
||||
|
||||
double distanceMeters = 0.0;
|
||||
final distanceCalculator = Distance();
|
||||
|
||||
for (int i = 0; i < points.length - 1; i++) {
|
||||
distanceMeters += distanceCalculator(points[i], points[i + 1]);
|
||||
}
|
||||
|
||||
return distanceMeters;
|
||||
}
|
||||
|
||||
String formatDistance(double distanceMeters, {required bool isImperial}) {
|
||||
if (isImperial) {
|
||||
return '(${(distanceMeters / 1609.34).toStringAsFixed(2)} mi)';
|
||||
}
|
||||
return '(${(distanceMeters / 1000).toStringAsFixed(2)} km)';
|
||||
}
|
||||
|
||||
class PathTraceData {
|
||||
final Uint8List pathData;
|
||||
final List<double> snrData;
|
||||
final Map<int, Contact> pathContacts;
|
||||
|
||||
PathTraceData({
|
||||
required this.pathData,
|
||||
required this.snrData,
|
||||
required this.pathContacts,
|
||||
});
|
||||
}
|
||||
|
||||
class PathTraceMapScreen extends StatefulWidget {
|
||||
final String title;
|
||||
final Uint8List path;
|
||||
final int? repeaterId;
|
||||
final bool flipPathAround;
|
||||
final bool reversePathAround;
|
||||
final Contact? targetContact;
|
||||
|
||||
const PathTraceMapScreen({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.path,
|
||||
this.repeaterId,
|
||||
this.flipPathAround = false,
|
||||
this.reversePathAround = false,
|
||||
this.targetContact,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PathTraceMapScreen> createState() => _PathTraceMapScreenState();
|
||||
}
|
||||
|
||||
class _PathTraceMapScreenState extends State<PathTraceMapScreen> {
|
||||
static const double _labelZoomThreshold = 8.5;
|
||||
|
||||
StreamSubscription<Uint8List>? _frameSubscription;
|
||||
Timer? _timeoutTimer;
|
||||
|
||||
bool _isLoading = false;
|
||||
bool _failed2Loaded = false;
|
||||
bool _hasData = false;
|
||||
PathTraceData? _traceData;
|
||||
// Inferred positions for hops that have no GPS location, keyed by hop byte.
|
||||
Map<int, LatLng> _inferredHopPositions = {};
|
||||
// Endpoint position for the target contact (GPS or guessed).
|
||||
LatLng? _targetContactPosition;
|
||||
bool _targetContactIsGuessed = false;
|
||||
List<LatLng> _points = <LatLng>[];
|
||||
List<Polyline> _polylines = [];
|
||||
LatLng? _initialCenter = LatLng(0, 0);
|
||||
double _initialZoom = 2.0;
|
||||
LatLngBounds? _bounds;
|
||||
ValueKey<String> _mapKey = const ValueKey('initial');
|
||||
double _pathDistanceMeters = 0.0;
|
||||
bool _showNodeLabels = true;
|
||||
Contact? _targetContact;
|
||||
|
||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||
return pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupFrameListener();
|
||||
_doPathTrace();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_frameSubscription?.cancel();
|
||||
_timeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Uint8List buildPath(Uint8List pathBytes) {
|
||||
Uint8List traceBytes;
|
||||
|
||||
if (pathBytes.isEmpty) {
|
||||
traceBytes = Uint8List(1);
|
||||
traceBytes[0] = widget.targetContact?.publicKey[0] ?? 0;
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
if (widget.targetContact?.type == advTypeRepeater ||
|
||||
widget.targetContact?.type == advTypeRoom) {
|
||||
final len = (pathBytes.length + pathBytes.length + 1);
|
||||
traceBytes = Uint8List(len);
|
||||
traceBytes[pathBytes.length] = widget.targetContact?.publicKey[0] ?? 0;
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (pathBytes.length < 2) {
|
||||
return pathBytes[0] == 0 ? Uint8List(0) : pathBytes;
|
||||
}
|
||||
final len = (pathBytes.length + pathBytes.length - 1);
|
||||
traceBytes = Uint8List(len);
|
||||
for (int i = 0; i < pathBytes.length; i++) {
|
||||
traceBytes[i] = pathBytes[i];
|
||||
if (i < pathBytes.length - 1) {
|
||||
traceBytes[len - 1 - i] = pathBytes[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return traceBytes;
|
||||
}
|
||||
|
||||
Future<void> _doPathTrace() async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_failed2Loaded = false;
|
||||
});
|
||||
}
|
||||
|
||||
final pathTmp = widget.reversePathAround
|
||||
? Uint8List.fromList(widget.path.reversed.toList())
|
||||
: widget.path;
|
||||
|
||||
final path = widget.flipPathAround ? buildPath(pathTmp) : pathTmp;
|
||||
|
||||
appLogger.info(
|
||||
'Initiating path trace with path: ${_formatPathPrefixes(path)}',
|
||||
tag: 'PathTraceMapScreen',
|
||||
noNotify: !mounted,
|
||||
);
|
||||
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final frame = buildTraceReq(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
0, //flags
|
||||
0, //auth
|
||||
payload: path,
|
||||
);
|
||||
connector.sendFrame(frame);
|
||||
}
|
||||
|
||||
void _setupFrameListener() {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
Uint8List tagData = Uint8List(4);
|
||||
// Listen for incoming text messages from the repeater
|
||||
_frameSubscription = connector.receivedFrames.listen((frame) {
|
||||
if (frame.isEmpty) return;
|
||||
final frameBuffer = BufferReader(frame);
|
||||
try {
|
||||
final code = frameBuffer.readUInt8();
|
||||
|
||||
if (code == respCodeSent) {
|
||||
frameBuffer.skipBytes(1); //reserved
|
||||
tagData = frameBuffer.readBytes(4);
|
||||
final timeoutMilliseconds = frameBuffer.readUInt32LE();
|
||||
|
||||
// Start timeout timer for trace response
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = Timer(
|
||||
Duration(milliseconds: timeoutMilliseconds),
|
||||
() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_failed2Loaded = true;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (code == respCodeErr) {
|
||||
_timeoutTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_failed2Loaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
// Check if it's a binary response
|
||||
if (frame.length > 8 &&
|
||||
code == pushCodeTraceData &&
|
||||
listEquals(frame.sublist(4, 8), tagData)) {
|
||||
_timeoutTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
frameBuffer.skipBytes(3); //reserved + path length + flag
|
||||
if (listEquals(frameBuffer.readBytes(4), tagData)) {
|
||||
_handleTraceResponse(frame);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
_timeoutTimer?.cancel();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_failed2Loaded = true;
|
||||
});
|
||||
// Handle any parsing errors gracefully
|
||||
appLogger.error('Error parsing frame: $e', tag: 'PathTraceMapScreen');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _handleTraceResponse(Uint8List frame) async {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
|
||||
final buffer = BufferReader(frame);
|
||||
try {
|
||||
buffer.skipBytes(2); // Skip push code and reserved byte
|
||||
int pathLength = buffer.readUInt8();
|
||||
buffer.skipBytes(5); // Skip Flag byte and tag data
|
||||
buffer.skipBytes(4); // Skip auth code
|
||||
Uint8List pathData = buffer.readBytes(pathLength);
|
||||
List<double> snrData = buffer
|
||||
.readRemainingBytes()
|
||||
.map((snr) => snr.toSigned(8).toDouble() / 4)
|
||||
.toList();
|
||||
|
||||
Map<int, Contact> pathContacts = {};
|
||||
final contacts = connector.allContacts;
|
||||
contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
|
||||
for (var repeaterData in pathData) {
|
||||
if (listEquals(
|
||||
repeater.publicKey.sublist(0, 1),
|
||||
Uint8List.fromList([repeaterData]),
|
||||
)) {
|
||||
pathContacts[repeaterData] = repeater;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// For hops with no GPS contact, infer position from other contacts
|
||||
// with known GPS that share the same last-hop byte.
|
||||
final Map<int, LatLng> inferredPositions = {};
|
||||
for (final hop in pathData) {
|
||||
final contact = pathContacts[hop];
|
||||
if (contact != null && contact.hasLocation) continue;
|
||||
final peers = connector.contacts
|
||||
.where(
|
||||
(c) => c.hasLocation && c.path.isNotEmpty && c.path.last == hop,
|
||||
)
|
||||
.toList();
|
||||
if (peers.isNotEmpty) {
|
||||
final lat =
|
||||
peers.map((c) => c.latitude!).reduce((a, b) => a + b) /
|
||||
peers.length;
|
||||
final lon =
|
||||
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
|
||||
peers.length;
|
||||
inferredPositions[hop] = LatLng(lat, lon);
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = true;
|
||||
_inferredHopPositions = inferredPositions;
|
||||
_traceData = PathTraceData(
|
||||
pathData: pathData,
|
||||
snrData: snrData,
|
||||
pathContacts: pathContacts,
|
||||
);
|
||||
// Compute endpoint position for the target contact.
|
||||
LatLng? targetPos;
|
||||
bool targetGuessed = false;
|
||||
_targetContact = widget.targetContact;
|
||||
|
||||
if (_targetContact != null) {
|
||||
final tc = _targetContact!;
|
||||
if (tc.hasLocation) {
|
||||
targetPos = LatLng(tc.latitude!, tc.longitude!);
|
||||
} else if (widget.path.length > 1) {
|
||||
// Infer from the last hop: average GPS contacts sharing that hop.
|
||||
// For a round-trip path (flipPathAround/reversePathAround), the target-side hop
|
||||
// sits in the middle of the symmetric sequence; .last is the local side.
|
||||
final lastHop = widget.reversePathAround
|
||||
? widget.path.first
|
||||
: widget.path.last;
|
||||
|
||||
final peers = connector.allContacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.hasLocation &&
|
||||
c.path.isNotEmpty &&
|
||||
c.path.last == lastHop,
|
||||
)
|
||||
.toList();
|
||||
if (peers.isNotEmpty) {
|
||||
final lat =
|
||||
peers.map((c) => c.latitude!).reduce((a, b) => a + b) /
|
||||
peers.length;
|
||||
final lon =
|
||||
peers.map((c) => c.longitude!).reduce((a, b) => a + b) /
|
||||
peers.length;
|
||||
const offsetDeg = 0.003;
|
||||
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
||||
targetPos = LatLng(
|
||||
lat + offsetDeg * cos(angle),
|
||||
lon + offsetDeg * sin(angle),
|
||||
);
|
||||
targetGuessed = true;
|
||||
} else if (inferredPositions.containsKey(lastHop)) {
|
||||
final lat = inferredPositions[lastHop]!.latitude;
|
||||
final lon = inferredPositions[lastHop]!.longitude;
|
||||
const offsetDeg = 0.003;
|
||||
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
||||
targetPos = LatLng(
|
||||
lat + offsetDeg * cos(angle),
|
||||
lon + offsetDeg * sin(angle),
|
||||
);
|
||||
targetGuessed = true;
|
||||
} else {
|
||||
// As a last resort, just place it at the same position as the last hop.
|
||||
final contact = pathContacts[lastHop];
|
||||
if (contact != null && contact.hasLocation) {
|
||||
const offsetDeg = 0.003;
|
||||
final angle = (tc.publicKey[1] / 255.0) * 2 * pi;
|
||||
targetPos = LatLng(
|
||||
contact.latitude! + offsetDeg * cos(angle),
|
||||
contact.longitude! + offsetDeg * sin(angle),
|
||||
);
|
||||
targetGuessed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_targetContactPosition = targetPos;
|
||||
_targetContactIsGuessed = targetGuessed;
|
||||
|
||||
_points = <LatLng>[];
|
||||
_points.add(LatLng(connector.selfLatitude!, connector.selfLongitude!));
|
||||
int hopLast = 0;
|
||||
int hopLastLast = 0;
|
||||
for (final hop in _traceData!.pathData) {
|
||||
if (hop == hopLastLast && widget.flipPathAround) {
|
||||
break; //skip duplicate hops in round-trip paths
|
||||
}
|
||||
final contact = _traceData!.pathContacts[hop];
|
||||
if (contact != null && contact.hasLocation) {
|
||||
_points.add(LatLng(contact.latitude!, contact.longitude!));
|
||||
} else {
|
||||
final inferred = inferredPositions[hop];
|
||||
if (inferred != null) _points.add(inferred);
|
||||
}
|
||||
hopLastLast = hopLast;
|
||||
hopLast = hop;
|
||||
}
|
||||
if (targetPos != null) {
|
||||
if (_targetContact != null && _targetContact!.type == advTypeChat) {
|
||||
_points.add(targetPos);
|
||||
}
|
||||
}
|
||||
_polylines = _points.length > 1
|
||||
? [
|
||||
Polyline(
|
||||
points: _points,
|
||||
strokeWidth: 4,
|
||||
color: Colors.blueAccent,
|
||||
),
|
||||
]
|
||||
: <Polyline>[];
|
||||
|
||||
_initialCenter = _points.isNotEmpty
|
||||
? _points.first
|
||||
: const LatLng(0, 0);
|
||||
_initialZoom = _points.isNotEmpty ? 13.0 : 2.0;
|
||||
_bounds = _points.length > 1 ? LatLngBounds.fromPoints(_points) : null;
|
||||
_mapKey = ValueKey(
|
||||
'${context.l10n.pathTrace_you},${_formatPathPrefixes(_traceData!.pathData)}',
|
||||
);
|
||||
_pathDistanceMeters = getPathDistanceMeters(_points);
|
||||
});
|
||||
} catch (e) {
|
||||
appLogger.error(
|
||||
'Error handling trace response: $e',
|
||||
tag: 'PathTraceMapScreen',
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_failed2Loaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final settings = context.watch<AppSettingsService>().settings;
|
||||
final isImperial = settings.unitSystem == UnitSystem.imperial;
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
widget.title,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: _isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: _isLoading ? null : _doPathTrace,
|
||||
tooltip: context.l10n.pathTrace_refreshTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (!_hasData)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isLoading) const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isLoading && _failed2Loaded)
|
||||
Text(context.l10n.pathTrace_notAvailable),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_hasData)
|
||||
_buildMapPathTrace(context, tileCache, _targetContact),
|
||||
if (_points.isEmpty &&
|
||||
!_hasData &&
|
||||
!_isLoading &&
|
||||
!_failed2Loaded)
|
||||
Center(
|
||||
child: Card(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(
|
||||
context.l10n.channelPath_noRepeaterLocations,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_hasData)
|
||||
_buildLegendCard(context, _traceData!, isImperial),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Marker> _buildHopMarkers(
|
||||
List<int> pathData, {
|
||||
required bool showLabels,
|
||||
required Contact? target,
|
||||
}) {
|
||||
final markers = <Marker>[];
|
||||
int hopLast = 0;
|
||||
int hopLastLast = 0;
|
||||
for (final hop in pathData) {
|
||||
final contact = _traceData!.pathContacts[hop];
|
||||
final inferred = _inferredHopPositions[hop];
|
||||
final hasGps = contact != null && contact.hasLocation;
|
||||
if (hop == hopLastLast && widget.flipPathAround) {
|
||||
continue; //skip duplicate hops in round-trip paths
|
||||
}
|
||||
if (!hasGps && inferred == null) {
|
||||
hopLastLast = hopLast;
|
||||
hopLast = hop;
|
||||
continue; //skip hops with no GPS and no inferred position
|
||||
}
|
||||
final point = hasGps
|
||||
? LatLng(contact.latitude!, contact.longitude!)
|
||||
: inferred!;
|
||||
final label = hop.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
|
||||
markers.add(
|
||||
Marker(
|
||||
point: point,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: hasGps
|
||||
? Colors.green
|
||||
: Colors.orange.withValues(alpha: 0.75),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
hasGps ? label : '~$label',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (showLabels) {
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: point,
|
||||
label: contact?.name ?? '~$label',
|
||||
),
|
||||
);
|
||||
}
|
||||
hopLastLast = hopLast;
|
||||
hopLast = hop;
|
||||
}
|
||||
|
||||
final selfLat = context.read<MeshCoreConnector>().selfLatitude;
|
||||
final selfLon = context.read<MeshCoreConnector>().selfLongitude;
|
||||
if (selfLat != null && selfLon != null) {
|
||||
final selfPoint = LatLng(selfLat, selfLon);
|
||||
markers.add(
|
||||
Marker(
|
||||
point: selfPoint,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
context.l10n.pathTrace_you,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (showLabels) {
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: selfPoint,
|
||||
label: context.l10n.pathTrace_you,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add target contact endpoint marker.
|
||||
final targetPos = _targetContactPosition;
|
||||
if (targetPos != null && target != null && target.type == advTypeChat) {
|
||||
final isGuessed = _targetContactIsGuessed;
|
||||
final targetName = target.name;
|
||||
markers.add(
|
||||
Marker(
|
||||
point: targetPos,
|
||||
width: 35,
|
||||
height: 35,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: isGuessed
|
||||
? Colors.purple.withValues(alpha: 0.55)
|
||||
: Colors.red,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(Icons.person, color: Colors.white, size: 18),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (showLabels) {
|
||||
markers.add(
|
||||
_buildNodeLabelMarker(
|
||||
point: targetPos,
|
||||
label: isGuessed ? '~$targetName' : targetName,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
Marker _buildNodeLabelMarker({required LatLng point, required String label}) {
|
||||
return Marker(
|
||||
point: point,
|
||||
width: 120,
|
||||
height: 24,
|
||||
alignment: Alignment.topCenter,
|
||||
child: IgnorePointer(
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, -20),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String formatDirectionText(PathTraceData pathTraceData, int index) {
|
||||
if (index == 0 || index == pathTraceData.snrData.length - 1) {
|
||||
if (index == 0) {
|
||||
return context.l10n.pathTrace_you;
|
||||
} else {
|
||||
final contactName = pathTraceData
|
||||
.pathContacts[pathTraceData.pathData[pathTraceData.pathData.length -
|
||||
1]]
|
||||
?.name;
|
||||
final hex = pathTraceData.pathData[pathTraceData.pathData.length - 1]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null
|
||||
? "$hex: $contactName"
|
||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||
}
|
||||
} else {
|
||||
final contactName =
|
||||
pathTraceData.pathContacts[pathTraceData.pathData[index - 1]]?.name;
|
||||
final hex = pathTraceData.pathData[index - 1]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null
|
||||
? "$hex: $contactName"
|
||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||
}
|
||||
}
|
||||
|
||||
String formatDirectionSubText(PathTraceData pathTraceData, int index) {
|
||||
if (index == 0 || index == pathTraceData.snrData.length - 1) {
|
||||
if (index == 0) {
|
||||
final contactName =
|
||||
pathTraceData.pathContacts[pathTraceData.pathData[0]]?.name;
|
||||
final hex = pathTraceData.pathData[0]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null
|
||||
? "$hex: $contactName"
|
||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||
} else {
|
||||
return context.l10n.pathTrace_you;
|
||||
}
|
||||
} else {
|
||||
final contactName =
|
||||
pathTraceData.pathContacts[pathTraceData.pathData[index]]?.name;
|
||||
final hex = pathTraceData.pathData[index]
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0')
|
||||
.toUpperCase();
|
||||
return contactName != null
|
||||
? "$hex: $contactName"
|
||||
: "$hex: ${context.l10n.channelPath_unknownRepeater}";
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMapPathTrace(
|
||||
BuildContext context,
|
||||
MapTileCacheService tileCache,
|
||||
Contact? target,
|
||||
) {
|
||||
return FlutterMap(
|
||||
key: _mapKey,
|
||||
options: MapOptions(
|
||||
interactionOptions: InteractionOptions(flags: ~InteractiveFlag.rotate),
|
||||
initialCenter: _initialCenter!,
|
||||
initialZoom: _initialZoom,
|
||||
initialCameraFit: _bounds == null
|
||||
? null
|
||||
: CameraFit.bounds(
|
||||
bounds: _bounds!,
|
||||
padding: const EdgeInsets.all(64),
|
||||
maxZoom: 16,
|
||||
),
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
onPositionChanged: (camera, hasGesture) {
|
||||
final shouldShow = camera.zoom >= _labelZoomThreshold;
|
||||
if (shouldShow != _showNodeLabels && mounted) {
|
||||
setState(() {
|
||||
_showNodeLabels = shouldShow;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName: MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
if (_polylines.isNotEmpty) PolylineLayer(polylines: _polylines),
|
||||
if (_traceData!.pathData.isNotEmpty)
|
||||
MarkerLayer(
|
||||
markers: _buildHopMarkers(
|
||||
_traceData!.pathData,
|
||||
showLabels: _showNodeLabels,
|
||||
target: target,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLegendCard(
|
||||
BuildContext context,
|
||||
PathTraceData pathTraceData,
|
||||
bool isImperial,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.35;
|
||||
final estimatedHeight = 72.0 + (pathTraceData.pathData.length * 56.0);
|
||||
final cardHeight = max(96.0, min(maxHeight, estimatedHeight));
|
||||
|
||||
return Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: SizedBox(
|
||||
height: cardHeight,
|
||||
child: Card(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'${l10n.channelPath_repeaterHops} ${formatDistance(_pathDistanceMeters, isImperial: isImperial)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: pathTraceData.pathData.isEmpty
|
||||
? Center(
|
||||
child: Text(l10n.channelPath_noHopDetailsAvailable),
|
||||
)
|
||||
: Scrollbar(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: pathTraceData.pathData.length + 1,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final snrUi = snrUiFromSNR(
|
||||
index < pathTraceData.snrData.length
|
||||
? pathTraceData.snrData[index]
|
||||
: null,
|
||||
context.read<MeshCoreConnector>().currentSf,
|
||||
);
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading:
|
||||
index >= pathTraceData.snrData.length / 2
|
||||
? Icon(Icons.call_received)
|
||||
: Icon(Icons.call_made),
|
||||
title: Text(
|
||||
formatDirectionText(pathTraceData, index),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
formatDirectionSubText(
|
||||
pathTraceData,
|
||||
index,
|
||||
),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
snrUi.icon,
|
||||
color: snrUi.color,
|
||||
size: 18.0,
|
||||
),
|
||||
Text(
|
||||
snrUi.text,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: snrUi.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
// Handle item tap
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -77,11 +77,22 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
int _resolveRepeaterIndex = -1;
|
||||
|
||||
Contact _resolveRepeater(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
if (_resolveRepeaterIndex >= 0 &&
|
||||
_resolveRepeaterIndex < connector.contacts.length &&
|
||||
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
|
||||
widget.repeater.publicKeyHex) {
|
||||
return connector.contacts[_resolveRepeaterIndex];
|
||||
}
|
||||
_resolveRepeaterIndex = connector.contacts.indexWhere(
|
||||
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
|
||||
orElse: () => widget.repeater,
|
||||
);
|
||||
if (_resolveRepeaterIndex == -1) {
|
||||
return widget.repeater;
|
||||
}
|
||||
return connector.contacts[_resolveRepeaterIndex];
|
||||
}
|
||||
|
||||
void _handleTextMessageResponse(Uint8List frame) {
|
||||
@@ -119,14 +130,24 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
|
||||
// Show debug info if requested
|
||||
if (showDebug && mounted) {
|
||||
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
|
||||
DebugFrameViewer.showFrameDebug(context, frame, context.l10n.repeater_cliCommandFrameTitle);
|
||||
final frame = buildSendCliCommandFrame(
|
||||
widget.repeater.publicKey,
|
||||
command,
|
||||
);
|
||||
DebugFrameViewer.showFrameDebug(
|
||||
context,
|
||||
frame,
|
||||
context.l10n.repeater_cliCommandFrameTitle,
|
||||
);
|
||||
}
|
||||
|
||||
// Send CLI command to repeater with retry
|
||||
try {
|
||||
if (_commandService != null) {
|
||||
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
|
||||
final connector = Provider.of<MeshCoreConnector>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final repeater = _resolveRepeater(connector);
|
||||
final response = await _commandService!.sendCommand(
|
||||
repeater,
|
||||
@@ -158,6 +179,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
|
||||
_commandController.clear();
|
||||
_historyIndex = -1;
|
||||
_commandFocusNode.requestFocus();
|
||||
|
||||
// Auto-scroll to bottom
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
@@ -230,7 +252,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
Text(l10n.repeater_cliTitle),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -251,12 +276,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
value: 'auto',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.auto_mode,
|
||||
size: 20,
|
||||
color: !isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_autoUseSavedPath,
|
||||
style: TextStyle(
|
||||
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: !isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -266,12 +299,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
value: 'flood',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null),
|
||||
Icon(
|
||||
Icons.waves,
|
||||
size: 20,
|
||||
color: isFloodMode
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.repeater_forceFloodMode,
|
||||
style: TextStyle(
|
||||
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
|
||||
fontWeight: isFloodMode
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -282,7 +323,8 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.timeline),
|
||||
tooltip: l10n.repeater_pathManagement,
|
||||
onPressed: () => PathManagementDialog.show(context, contact: repeater),
|
||||
onPressed: () =>
|
||||
PathManagementDialog.show(context, contact: repeater),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bug_report),
|
||||
@@ -473,7 +515,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.repeater_enterCommandHint,
|
||||
border: const OutlineInputBorder(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
prefixText: '> ',
|
||||
),
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
@@ -718,10 +763,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
];
|
||||
|
||||
final gpsCommands = [
|
||||
_CommandHelpEntry(
|
||||
command: 'gps',
|
||||
description: l10n.repeater_cliHelpGps,
|
||||
),
|
||||
_CommandHelpEntry(command: 'gps', description: l10n.repeater_cliHelpGps),
|
||||
_CommandHelpEntry(
|
||||
command: 'gps {on|off}',
|
||||
description: l10n.repeater_cliHelpGpsOnOff,
|
||||
@@ -758,13 +800,25 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_general, generalCommands),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_general,
|
||||
generalCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_settingsCategory,
|
||||
settingsCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(context, l10n.repeater_logging, loggingCommands),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
l10n.repeater_logging,
|
||||
loggingCommands,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildHelpSection(
|
||||
context,
|
||||
@@ -813,10 +867,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
|
||||
),
|
||||
if (note != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
note,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
Text(note, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
...commands.map((entry) => _buildHelpCommandCard(context, entry)),
|
||||
@@ -871,8 +922,5 @@ class _CommandHelpEntry {
|
||||
final String command;
|
||||
final String description;
|
||||
|
||||
const _CommandHelpEntry({
|
||||
required this.command,
|
||||
required this.description,
|
||||
});
|
||||
const _CommandHelpEntry({required this.command, required this.description});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_protocol.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import 'repeater_status_screen.dart';
|
||||
import 'repeater_cli_screen.dart';
|
||||
import 'repeater_settings_screen.dart';
|
||||
import 'telemetry_screen.dart';
|
||||
import 'neighbors_screen.dart';
|
||||
|
||||
class RepeaterHubScreen extends StatelessWidget {
|
||||
final Contact repeater;
|
||||
@@ -19,16 +23,27 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final settingsService = context.watch<AppSettingsService>();
|
||||
final chemistry = settingsService.batteryChemistryForRepeater(
|
||||
repeater.publicKeyHex,
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.repeater_management),
|
||||
Text(
|
||||
repeater.type == advTypeRepeater
|
||||
? l10n.repeater_management
|
||||
: l10n.room_management,
|
||||
),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -39,130 +54,220 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Repeater info card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: Colors.orange,
|
||||
child: const Icon(Icons.cell_tower, size: 40, color: Colors.white),
|
||||
// Repeater info card
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 40,
|
||||
backgroundColor: Colors.orange,
|
||||
child: const Icon(
|
||||
Icons.cell_tower,
|
||||
size: 40,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
repeater.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
repeater.pathLabel,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
),
|
||||
if (repeater.hasLocation) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.location_on, size: 14, color: Colors.grey[600]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
repeater.shortPubKeyHex,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
repeater.pathLabel,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
),
|
||||
if (repeater.hasLocation) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.location_on,
|
||||
size: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${repeater.latitude?.toStringAsFixed(4)}, ${repeater.longitude?.toStringAsFixed(4)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
l10n.repeater_managementTools,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Status button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.analytics,
|
||||
title: l10n.repeater_status,
|
||||
subtitle: l10n.repeater_statusSubtitle,
|
||||
color: Colors.blue,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterStatusScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.battery_full),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.appSettings_batteryChemistry,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Telemetry button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.bar_chart_sharp,
|
||||
title: l10n.repeater_telemetry,
|
||||
subtitle: l10n.repeater_telemetrySubtitle,
|
||||
color: Colors.teal,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TelemetryScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: chemistry,
|
||||
isExpanded: true,
|
||||
decoration: const InputDecoration(
|
||||
border: UnderlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
settingsService.setBatteryChemistryForRepeater(
|
||||
repeater.publicKeyHex,
|
||||
value,
|
||||
);
|
||||
},
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 'nmc',
|
||||
child: Text(l10n.appSettings_batteryNmc),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'lifepo4',
|
||||
child: Text(l10n.appSettings_batteryLifepo4),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'lipo',
|
||||
child: Text(l10n.appSettings_batteryLipo),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// CLI button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.terminal,
|
||||
title: l10n.repeater_cli,
|
||||
subtitle: l10n.repeater_cliSubtitle,
|
||||
color: Colors.green,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterCliScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
l10n.repeater_managementTools,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Status button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.analytics,
|
||||
title: l10n.repeater_status,
|
||||
subtitle: l10n.repeater_statusSubtitle,
|
||||
color: Colors.blue,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterStatusScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Settings button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.settings,
|
||||
title: l10n.repeater_settings,
|
||||
subtitle: l10n.repeater_settingsSubtitle,
|
||||
color: Colors.orange,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterSettingsScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Telemetry button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.bar_chart_sharp,
|
||||
title: l10n.repeater_telemetry,
|
||||
subtitle: l10n.repeater_telemetrySubtitle,
|
||||
color: Colors.teal,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TelemetryScreen(contact: repeater),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// CLI button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.terminal,
|
||||
title: l10n.repeater_cli,
|
||||
subtitle: l10n.repeater_cliSubtitle,
|
||||
color: Colors.green,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterCliScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Neighbors button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.group,
|
||||
title: l10n.repeater_neighbors,
|
||||
subtitle: l10n.repeater_neighborsSubtitle,
|
||||
color: Colors.orange,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
NeighborsScreen(repeater: repeater, password: password),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Settings button
|
||||
_buildManagementCard(
|
||||
context,
|
||||
icon: Icons.settings,
|
||||
title: l10n.repeater_settings,
|
||||
subtitle: l10n.repeater_settingsSubtitle,
|
||||
color: Colors.deepOrange,
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RepeaterSettingsScreen(
|
||||
repeater: repeater,
|
||||
password: password,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -209,10 +314,7 @@ class RepeaterHubScreen extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user