Compare commits

..

94 Commits

Author SHA1 Message Date
Janez T
5834454087 feat: Add UTM coordinates #38
ref:
2026-04-26 08:28:41 +02:00
Janez T
73e78691ed fix: Split trail recording #32
ref:
2026-04-26 08:24:11 +02:00
Janez T
d067095345 fix: Add background service #37
ref: #31
2026-04-26 08:24:09 +02:00
Janez T
0b463f6454 fix: Stabilize reported tests #31
ref: #32 #37
2026-04-26 08:24:07 +02:00
Janez T
a472b0c05c chore: Add Play release cycle #31
ref:
2026-04-26 08:24:04 +02:00
Janez T
4181b33da1 Fix echo metadata and bump build number #123 2026-04-26 08:02:53 +02:00
Janez T
f9dfdc2340 fix: Add manual TCP connect flow 2026-04-18 08:59:36 +02:00
Janez T
b333b3737e feat: Open per-metric sensor history 2026-04-12 21:07:53 +02:00
Janez T
7245d76e17 chore: Bump iOS build number 2026-04-08 18:45:52 +02:00
Janez T
7a76ba97f8 fix: Reset test viewport state 2026-04-08 18:43:37 +02:00
Janez T
d00198c829 Fix radio preset resync 2026-04-08 18:43:12 +02:00
Janez T
e6cd59e96f fix: Remove GPS interval option 2026-04-08 18:41:58 +02:00
Janez T
b863cda139 fix: Prefer cached map tiles offline ref: 2026-04-08 09:46:31 +02:00
Janez T
4bd7f36b98 chore: Bump iOS build version 2026-04-07 14:21:39 +02:00
Janez T
44a668df93 fix: Reset fallback sharing state 2026-04-07 14:04:45 +02:00
Janez T
e5514b4ef9 Fix fast location cadence ref: 2026-04-07 10:35:31 +02:00
Janez T
d92e52c5c2 fix: Dedupe replayed messages #123 2026-04-06 21:00:07 +02:00
Janez T
daa88fb471 chore: Update fastlane report 2026-04-06 20:55:27 +02:00
Janez T
c182813c35 fix: Add manual TCP IP 2026-04-05 21:51:17 +02:00
Janez T
81944bfd3c feat: Refresh generated API snapshots #0 2026-04-05 21:48:19 +02:00
Janez T
43eb857663 Bump iOS build to 133 2026-04-05 20:07:19 +02:00
Janez T
faa396769e feat: Add buzzer alerts toggle 2026-04-05 20:02:54 +02:00
Janez T
1a35b790a7 feat: Add GPS diagnostics #0 2026-04-05 19:58:34 +02:00
Janez T
573921e002 fix: Clarify shared location labels and onboarding copy #123 2026-04-05 19:54:48 +02:00
Janez T
14a41b210e feat: Add manual TCP IP entry 2026-04-05 19:45:16 +02:00
Janez T
56a7fa2fc6 fix: Use location pin icon 2026-04-05 19:39:40 +02:00
Janez T
7f4b4c4560 feat: Add message location details 2026-04-05 19:33:41 +02:00
Janez T
7f7f4aa975 fix: Widen worker charts 2026-04-05 18:01:05 +02:00
Janez T
1baab06627 fix: Widen worker stats graph 2026-04-04 20:20:36 +02:00
Janez T
5320fa1a2b fix: Guard null history decode #123 2026-04-04 15:58:21 +02:00
Janez T
512b64db9f fix: Preserve custom path priority 2026-04-04 15:57:41 +02:00
Janez T
4690ceead1 chore: Remove reporter map dependencies 2026-04-04 10:01:03 +02:00
Janez T
d431a79aa8 Fix: Limit stats retention to 7 days 2026-04-03 22:42:52 +02:00
Janez T
d66bbca82c feat: Add dark theme charts ref: 2026-04-03 22:38:21 +02:00
Janez T
d2fee94678 feat: Reorder stats cards cache dashboard 2026-04-03 22:34:06 +02:00
Janez T
61a856e84c feat: Clarify stats dashboard ref: 2026-04-03 22:29:46 +02:00
Janez T
5feb07aad7 feat: Enable stats by default and enrich public dashboard 2026-04-03 22:24:09 +02:00
Janez T
59931f402a feat: Add traffic over time chart 2026-04-03 22:19:23 +02:00
Janez T
0cd563063a feat: Add Leaflet map to stats dashboard #123 2026-04-03 22:17:23 +02:00
Janez T
9244368b49 chore: Rename worker stats #0 2026-04-03 20:00:14 +02:00
Janez T
c089db5c62 feat: Add anonymous RX stats
ref:
2026-04-03 19:52:45 +02:00
Janez T
7c1bc3b84f feat: Add shadcn worker UI 2026-04-03 19:46:51 +02:00
Janez T
724f4b38e8 fix: Tighten ping sheet timing 2026-04-03 12:04:33 +02:00
Janez T
caefbdbcef fix: Clamp auto ping history 2026-04-03 12:01:39 +02:00
Janez T
79f8f8dd04 fix: Reuse Ping sheet for Sensors #123 2026-04-03 11:55:52 +02:00
Janez T
e4eb40cd34 fix: Hide Cleared Paths, Unify Sensor Actions #0 2026-04-03 11:38:57 +02:00
Janez T
7c9dfd6f63 fix: Promote confirmed DM paths 2026-04-03 10:27:06 +02:00
Janez T
3e8d4c2d11 fix: Use GitHub client #1928
ref:
2026-04-02 08:52:25 +02:00
Janez T
3a28acac2d feat: Improve live traffic packet help #0 2026-04-01 20:58:13 +02:00
Janez T
5ee03d4b2f chore: Pin Meshcore client git ref #0 2026-04-01 15:11:52 +02:00
Janez T
3dfdbd7700 fix: Preserve contacts and sensor updates 2026-04-01 13:48:40 +02:00
Janez T
043faea06a fix: Dedupe recent messages and wasm 2026-04-01 13:35:02 +02:00
Janez T
05ddb6841a fix: Use zero-hop relay ping history 2026-03-31 18:13:53 +02:00
Janez T
3392e5f9c1 feat: Add relay ping and lock message destination 2026-03-31 17:52:44 +02:00
Janez T
7df750c8d5 feat: Add send mode localization #123 2026-03-31 09:41:45 +02:00
Janez T
e09d418084 fix: Correct neighbor times ref: 2026-03-29 11:10:25 +02:00
Janez T
96aeec1301 chore: Bump iOS build number ref: 2026-03-24 19:29:20 +01:00
Janez T
6978f24440 fix: Merge self replay and refresh self telemetry 2026-03-24 19:07:44 +01:00
Janez T
ba0f1fc141 feat: Add fast GPS send logs 2026-03-23 21:00:41 +01:00
Janez T
28a9235168 fix: Tame rapid GPS updates #41 2026-03-23 20:51:48 +01:00
Janez T
3e3c9a34d5 fix: Keep received replays separate 2026-03-23 20:07:06 +01:00
Janez T
e85dccea35 feat: Add location-based DM retries 2026-03-23 15:16:59 +01:00
Janez T
019224d955 fix: Hide public channel and show signal pills #123 2026-03-23 15:04:45 +01:00
Janez T
4c048a5a50 fix: Show recipient activity previews 2026-03-23 10:44:20 +01:00
Janez T
2b42e7dd15 feat: Add compass and region support 2026-03-22 19:04:43 +01:00
Janez T
7ea6334f02 fix: Keep composer and sensor sheets closable #123 2026-03-22 18:27:57 +01:00
Janez Troha
6f0a66dc68 Merge pull request #35 from dz0ny/feat/channel-datagrams
feat: Add repeater discovery onboarding flow
2026-03-22 18:06:59 +01:00
Janez T
a26ae96acd feat: Add repeater discovery onboarding flow 2026-03-22 18:06:24 +01:00
Janez Troha
a6dc137e51 Merge pull request #34 from dz0ny/feat/channel-datagrams
feat: Add channel datagrams #1928
2026-03-22 17:08:34 +01:00
Janez Troha
c2501442e3 Merge pull request #33 from dz0ny/feat/update-changelog-with-highlights
Update changelog with weekly highlights and PR links
2026-03-22 17:08:13 +01:00
Janez T
71eac21df9 feat: Add channel datagrams #1928
ref:
2026-03-22 16:46:58 +01:00
Janez T
c299349b6a chore: Update changelog #0 2026-03-22 16:02:17 +01:00
Janez T
ab05f13de9 fix: Remove contact trails from map #123 2026-03-22 15:48:55 +01:00
Janez T
0e61dc5bf1 fix: Revert Darwin BLE override 2026-03-22 13:57:14 +01:00
Janez T
d45924d7b4 fix: Vendor Darwin BLE override 2026-03-22 13:56:23 +01:00
Janez T
a24e699e22 fix: Refresh MeshCore lock #31
ref: dz0ny/meshcore-sar#31
2026-03-22 09:49:16 +01:00
Janez T
7486837ad0 fix: Compact recipient selector #0 2026-03-22 09:37:53 +01:00
Janez T
059125c714 fix: Restore telemetry refresh behavior
ref:
2026-03-22 09:19:33 +01:00
Janez T
9279ff2158 chore: Bump iOS build number #123 2026-03-21 20:45:09 +01:00
Janez T
dc297b0b9f fix: Tighten device settings layout 2026-03-21 20:44:29 +01:00
Janez T
2d24481aba feat: Add MET history isolation 2026-03-21 19:22:43 +01:00
Janez T
20a1df94eb fix: Support MeshCore message links #123 2026-03-21 18:36:56 +01:00
Janez T
5cc81bef65 feat: Show self sensor default 2026-03-20 19:08:24 +01:00
Janez T
6145462555 chore: Bump iOS build #123 2026-03-19 21:39:51 +01:00
Janez T
721fb7e7a2 feat: Add serial compass support #0 2026-03-19 21:38:54 +01:00
Janez T
5a58e45e13 fix: Add contacts test providers 2026-03-19 14:28:10 +01:00
Janez T
de4258c0a4 fix: Resolve DeviceInfo import clash 2026-03-19 14:18:48 +01:00
Janez T
d8cb35c595 fix: Simplify device settings styling #123 2026-03-19 14:18:29 +01:00
Janez T
8da69705ee fix: Align contact sorting labels #0 2026-03-19 14:06:05 +01:00
Janez T
e474168ba8 feat: Show self telemetry in device info sheet
Tapping signal/battery in header now also shows device's own telemetry
(temperature, humidity, pressure, GPS) when available. Triggers a self
telemetry request on sheet open. Data comes from the device contact's
telemetry field.
2026-03-19 13:52:15 +01:00
Janez T
03dba8b3ce feat: Device info sheet on signal/battery tap
Tapping the signal bars or battery indicator in the header now shows
a bottom sheet with device details: BLE signal (dBm), battery %,
voltage, storage usage, firmware version, frequency, and TX power.

Fixes #22 (partial — device telemetry display)
2026-03-19 11:07:08 +01:00
Janez T
bc9e37bd22 fix: Count RX data and speed up Flutter builds #123 2026-03-19 10:33:20 +01:00
Janez T
a32d5d2c85 fix: Show irradiance values #123 2026-03-19 09:24:53 +01:00
Janez T
e683ab933f fix: Show original poster name in room messages, not room server name
Room servers forward messages with the original author's 4-byte public
key prefix (roomPostAuthorPrefix). Was ignoring this — all room messages
showed the room server's name as sender.

Now resolves the actual poster's name from roomPostAuthorPrefix by
matching against the contacts list. Falls back to room server name if
the author isn't a known contact.

Also fixes findContactByPrefix to accept 4-byte prefixes (was rejecting
anything shorter than 6 bytes).

Fixes #22
2026-03-19 09:20:22 +01:00
209 changed files with 43149 additions and 16704 deletions

View File

@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Java
uses: actions/setup-java@v4
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
@@ -40,6 +40,17 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Setup Ruby
if: github.event_name == 'release'
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true
working-directory: android
- name: Cache Android NDK
uses: actions/cache@v4
@@ -52,22 +63,76 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Validate Android release secrets
if: github.event_name == 'release'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: |
test -n "$ANDROID_KEYSTORE_BASE64"
test -n "$ANDROID_KEYSTORE_PASSWORD"
test -n "$ANDROID_KEY_ALIAS"
test -n "$ANDROID_KEY_PASSWORD"
test -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
- name: Create Android signing files
if: github.event_name == 'release'
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
echo "$ANDROID_KEYSTORE_BASE64" | base64 --decode > android/release-keystore.jks
cat <<EOF > android/key.properties
storePassword=$ANDROID_KEYSTORE_PASSWORD
keyPassword=$ANDROID_KEY_PASSWORD
keyAlias=$ANDROID_KEY_ALIAS
storeFile=../release-keystore.jks
EOF
- name: Build APK
run: flutter build apk --release
- name: Prepare APK artifact
- name: Build Play release AAB
if: github.event_name == 'release'
run: flutter build appbundle --release
- name: Prepare Android artifacts
run: |
RAW_TAG="${{ github.event.release.tag_name || github.ref_name }}"
TAG="${RAW_TAG//\//-}"
cp build/app/outputs/flutter-apk/app-release.apk "${APP_NAME}-${TAG}-android.apk"
if [ -f build/app/outputs/bundle/release/app-release.aab ]; then
cp build/app/outputs/bundle/release/app-release.aab "${APP_NAME}-${TAG}-play.aab"
fi
- name: Upload APK artifact
- name: Upload Android artifacts
uses: actions/upload-artifact@v4
with:
name: release-android
path: "${{ env.APP_NAME }}-*-android.apk"
path: |
${{ env.APP_NAME }}-*-android.apk
${{ env.APP_NAME }}-*-play.aab
if-no-files-found: error
- name: Upload Play release to internal testing
if: github.event_name == 'release'
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
SKIP_ANDROID_BUILD: "1"
run: cd android && bundle exec fastlane android internal
- name: Upload Play release to production
if: github.event_name == 'release' && !github.event.release.prerelease
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
SKIP_ANDROID_BUILD: "1"
run: cd android && bundle exec fastlane android production
build-linux:
name: Build Linux App
runs-on: ubuntu-latest
@@ -95,6 +160,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable Linux desktop
run: flutter config --enable-linux-desktop
@@ -132,6 +200,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable Windows desktop
shell: pwsh
@@ -174,6 +245,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable macOS desktop
run: flutter config --enable-macos-desktop
@@ -181,6 +255,9 @@ jobs:
- name: Install dependencies
run: flutter pub get
- name: Install macOS native build tools
run: brew install automake libtool
- name: Build macOS release
run: flutter build macos --release
@@ -216,6 +293,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Install dependencies
run: flutter pub get
@@ -272,6 +352,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Enable web
run: flutter config --enable-web
@@ -280,7 +363,7 @@ jobs:
run: flutter pub get
- name: Build web release
run: flutter build web --release --base-href /meshcore-sar/
run: flutter build web --wasm --release --base-href /meshcore-sar/
- name: Upload pages artifact
uses: actions/upload-pages-artifact@v3
@@ -327,6 +410,7 @@ jobs:
body: |
Release assets are published for all supported build targets:
- Android (`.apk`)
- Google Play (`.aab`)
- Linux (`.tar.gz`)
- macOS (`.dmg`)
- Windows (`.zip`)
@@ -341,6 +425,7 @@ jobs:
4. Install the signed `.ipa` on your iPhone using Apple Configurator 2 or Xcode (Devices and Simulators).
files: |
dist/*.apk
dist/*.aab
dist/*.tar.gz
dist/*.zip
dist/*.dmg

View File

@@ -27,6 +27,9 @@ jobs:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: 'stable'
cache: true
pub-cache: true
cache-key: flutter-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}
pub-cache-key: flutter-pub-${{ runner.os }}-stable-${{ env.FLUTTER_VERSION }}-${{ runner.arch }}-${{ hashFiles('pubspec.lock') }}
- name: Install dependencies
run: flutter pub get

6
.gitignore vendored
View File

@@ -79,9 +79,15 @@ play-store/
create_feature_graphic.py
# Tool caches and local state
.devenv/
.cachebro/
.osgrep/
third_party/lpcnet_flutter/
worker/node_modules/
worker/.wrangler/
worker/.bun/
worker/.astro/
worker/dist/
# Claude Code local settings (permissions, personal config)
.claude/settings.local.json

View File

@@ -1,5 +1,17 @@
# Changelog
## 2026-03-15 to 2026-03-22
- Device connectivity and routing: added Android USB serial companion support, serial compass support, GPS module controls, per-contact path size selection, and favourites support via firmware flags.
- Location and map intelligence: added RSSI-based repeater trilateration and contact location estimates, isolated MET history, matched the official advert import/discovery flow, and refreshed map presentation by removing contact trails.
- Telemetry and device info: surfaced self telemetry and sensor defaults, opened device info from signal and battery taps, improved signal chip behavior, fixed duplicate telemetry delivery, and restored telemetry refresh behavior.
- Contacts and messaging: added meshcore:// contact sharing and repeater neighbour views, supported MeshCore message links, fixed DM retry behavior, corrected room poster naming, and compacted the recipient selector.
- UI polish and platform upkeep: improved device settings layout, tightened sensor and contact presentation, expanded localization coverage, and refreshed platform dependencies and build numbers.
## Key PRs
- [#26](https://github.com/dz0ny/meshcore-sar/pull/26) Merge pull request #26 from `dz0ny/feat/sensor-telemetry-preview`
## 2026-03-08 to 2026-03-14
- Discovery and contacts: finished the discovery flow, removed artificial UI delays, added profile storage and sync, and allowed contact name overrides.

View File

@@ -135,6 +135,9 @@ clean: ## Clean build artifacts
flutter clean
rm -rf $(BUILD_DIR)
build-web: ## Build web with WebAssembly
flutter build web --wasm --release
# Build for all platforms
build-all: bump ## Build for Android and iOS
flutter build apk --release

4
android/Gemfile Normal file
View File

@@ -0,0 +1,4 @@
source "https://rubygems.org"
gem "fastlane"
gem "ostruct"

View File

@@ -1,4 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Bluetooth permissions -->
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
@@ -62,6 +63,12 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:exported="false"
android:foregroundServiceType="location"
android:stopWithTask="false"
tools:replace="android:exported" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -16,44 +16,72 @@
default_platform(:android)
platform :android do
def project_root
File.expand_path("../..", __dir__)
end
def play_service_account_json
ENV.fetch("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON")
end
def play_build_command
"cd #{project_root} && flutter build appbundle --release"
end
def play_aab_path
File.join(project_root, "build/app/outputs/bundle/release/app-release.aab")
end
def play_package_name
"com.meshcore.sar.meshcore_sar_app"
end
def direct_apk_build_command
"cd #{project_root} && flutter build apk --release"
end
def direct_apk_path
File.join(project_root, "build/app/outputs/flutter-apk/app-release.apk")
end
def skip_android_build?
ENV["SKIP_ANDROID_BUILD"] == "1"
end
desc "Runs all the tests"
lane :test do
gradle(task: "test")
end
desc "Build release APK"
lane :beta do
# Get the project root directory (two levels up from android/fastlane/)
project_root = File.expand_path("../..", __dir__)
lane :direct_apk do
sh(direct_apk_build_command)
# Build APK with Flutter
sh("cd #{project_root} && flutter build apk --release")
apk_path = File.join(project_root, "build/app/outputs/flutter-apk/app-release.apk")
# Uncomment to distribute via Firebase App Distribution:
# firebase_app_distribution(
# app: "YOUR_FIREBASE_APP_ID",
# apk_path: apk_path,
# groups: "testers"
# )
UI.success("APK built at: #{apk_path}")
UI.success("APK built successfully at: #{direct_apk_path}")
end
desc "Deploy a new version to the Google Play"
lane :deploy do
# Get the project root directory (two levels up from android/fastlane/)
project_root = File.expand_path("../..", __dir__)
# Build AAB with Flutter (required for Play Store since 2021)
sh("cd #{project_root} && flutter build appbundle --release")
aab_path = File.join(project_root, "build/app/outputs/bundle/release/app-release.aab")
desc "Build Play release AAB and upload to internal testing"
lane :internal do
sh(play_build_command) unless skip_android_build?
upload_to_play_store(
aab: aab_path,
track: "internal"
aab: play_aab_path,
package_name: play_package_name,
track: "internal",
release_status: "draft",
json_key_data: play_service_account_json
)
end
desc "Build Play release AAB and upload to production"
lane :production do
sh(play_build_command) unless skip_android_build?
upload_to_play_store(
aab: play_aab_path,
package_name: play_package_name,
track: "production",
json_key_data: play_service_account_json
)
end
end

128
docs/google-play-release.md Normal file
View File

@@ -0,0 +1,128 @@
# Google Play Release Runbook
## Purpose
This runbook prepares and ships the Android release build for `com.meshcore.sar.meshcore_sar_app`.
## Distribution Modes
- GitHub release: signed APK for direct install or manual distribution.
- Google Play: signed AAB uploaded through Fastlane to Play internal testing and, for non-prerelease GitHub releases, production.
## Required Secrets and Local Files
GitHub Actions secrets for `dz0ny/meshcore-sar`:
- `ANDROID_KEYSTORE_BASE64`
- `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS`
- `ANDROID_KEY_PASSWORD`
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
Local files used to seed the secrets:
- `android/key.properties`
- release keystore referenced by `android/key.properties`
- `/Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json`
The Play Console service account must have release access for `com.meshcore.sar.meshcore_sar_app`.
## CI Release Flow
The release flow runs from `.github/workflows/build-artifacts.yml` when a GitHub release is published.
Android release steps:
1. Validate all Android signing and Google Play secrets.
2. Recreate `android/release-keystore.jks` and `android/key.properties` from GitHub secrets.
3. Build the signed APK with `flutter build apk --release`.
4. Build the signed Play AAB with `flutter build appbundle --release`.
5. Upload the APK and AAB as GitHub release assets.
6. Upload the AAB to Play internal testing with `bundle exec fastlane android internal`.
7. Upload the AAB to Play production when the GitHub release is not marked as prerelease.
The same workflow also builds Linux, macOS, Windows, iOS unsigned, and web artifacts.
## Versioning Rules
- Version source of truth: `pubspec.yaml`.
- Android `versionCode` is the number after `+`.
- `versionCode` must always increase for Play uploads.
- `make bump`, `make build`, and `make bundle` increment the version.
- Use `make build-no-bump` or `make bundle-no-bump` only when intentionally rebuilding the same version locally.
## Manual Build Commands
Run commands from the repo root.
```bash
flutter build apk --release
flutter build appbundle --release
```
Repo shortcuts:
```bash
make build
make bundle
make build-no-bump
make bundle-no-bump
```
## Fastlane Lanes
Run commands from `android/`.
```bash
bundle exec fastlane android direct_apk
bundle exec fastlane android internal
bundle exec fastlane android production
```
Set `SKIP_ANDROID_BUILD=1` when an AAB has already been built and Fastlane should only upload it.
```bash
SKIP_ANDROID_BUILD=1 GOOGLE_PLAY_SERVICE_ACCOUNT_JSON="$(cat /Users/dz0ny/android-keystores/fastlane-480919-0cb30c62db50.json)" bundle exec fastlane android internal
```
## Expected Artifacts
- APK: `build/app/outputs/flutter-apk/app-release.apk`
- AAB: `build/app/outputs/bundle/release/app-release.aab`
- Release APK asset: `meshcore-sar-<tag>-android.apk`
- Release AAB asset: `meshcore-sar-<tag>-play.aab`
## Internal Testing Release Checklist
1. Confirm `pubspec.yaml` version is correct and `versionCode` increased.
2. Confirm `android/key.properties` points to the release keystore for local builds.
3. Confirm all five GitHub Actions secrets exist.
4. Publish a prerelease in GitHub to build assets and upload Play internal testing without production promotion.
5. Install from the internal testing track on a real Android device.
6. Smoke test startup, permissions, map, messaging, telemetry, and offline map behavior.
## Production Release Checklist
1. Complete internal testing validation first.
2. Confirm Play Console forms are current:
- App content
- Data safety
- App access
- Ads declaration
- Content rating
3. Confirm store assets are current:
- app icon
- feature graphic
- phone screenshots
- tablet screenshots if used
- support URL
- privacy policy URL
4. Publish a non-prerelease GitHub release.
5. Confirm the GitHub release has the APK and AAB assets.
6. Confirm Play Console has the new internal and production release entries.
## Rollback Rules
- Never reuse or lower an Android `versionCode`.
- If a Play release is bad, halt rollout in Play Console and ship a new higher-version fix.
- Keep GitHub direct APK and Play AAB artifacts separate; do not upload APKs to Play.

View File

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

View File

@@ -43,7 +43,7 @@
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>119</string>
<string>141</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.00103">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000252">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="1.372199">
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.48394">
</testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="121.394124">
<testcase classname="fastlane.lanes" name="2: build_app" time="153.436107">
</testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="365.414242">
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="6642.682605">
</testcase>

View File

@@ -36,10 +36,6 @@
"@scanAgain": {
"description": "Schaltfläche zum Neustart des BLE-Scans"
},
"tapToConnect": "Zum Verbinden tippen",
"@tapToConnect": {
"description": "Untertiteltext für Gerät in der Scanliste"
},
"deviceNotConnected": "Gerät nicht verbunden",
"@deviceNotConnected": {
"description": "Fehlermeldung, wenn das Gerät nicht verbunden ist"
@@ -133,18 +129,10 @@
"@showRxTxIndicators": {
"description": "Einstellung zum Anzeigen von RX/TX-Indikatoren"
},
"displayPacketActivity": "Paketaktivitätsindikatoren in der oberen Leiste anzeigen",
"@displayPacketActivity": {
"description": "Beschreibung für die Einstellung der RX/TX-Indikatoren"
},
"disableMap": "Karte deaktivieren",
"@disableMap": {
"description": "Einstellung zum Deaktivieren des Karten-Tabs"
},
"disableMapDescription": "Karten-Tab ausblenden, um Akku zu sparen",
"@disableMapDescription": {
"description": "Beschreibung für die Einstellung zum Deaktivieren der Karte"
},
"language": "Sprache",
"@language": {
"description": "Beschriftung der Spracheinstellung"
@@ -173,10 +161,6 @@
"@appVersion": {
"description": "Beschriftung der App-Version"
},
"appName": "App-Name",
"@appName": {
"description": "Beschriftung des App-Namens"
},
"aboutMeshCoreSar": "Über MeshCore SAR",
"@aboutMeshCoreSar": {
"description": "Titel des Über-Dialogs"
@@ -308,10 +292,6 @@
"@resetPath": {
"description": "Aktion zum Zurücksetzen des Kontaktpfads für Umleitung"
},
"publicKeyCopied": "Öffentlicher Schlüssel in die Zwischenablage kopiert",
"@publicKeyCopied": {
"description": "Erfolgsmeldung, wenn der öffentliche Schlüssel kopiert wurde"
},
"copiedToClipboard": "{label} in die Zwischenablage kopiert",
"@copiedToClipboard": {
"description": "Erfolgsmeldung, wenn ein Wert in die Zwischenablage kopiert wurde",
@@ -720,22 +700,6 @@
"@lastSeen": {
"description": "Beschriftung Zuletzt gesehen"
},
"roomStatus": "Raumstatus",
"@roomStatus": {
"description": "Überschrift des Raumstatus-Bereichs"
},
"loginStatus": "Anmeldestatus",
"@loginStatus": {
"description": "Beschriftung des Anmeldestatus"
},
"notLoggedIn": "Nicht angemeldet",
"@notLoggedIn": {
"description": "Nicht angemeldet-Status"
},
"adminAccess": "Admin-Zugriff",
"@adminAccess": {
"description": "Beschriftung des Admin-Zugriffs"
},
"yes": "Ja",
"@yes": {
"description": "Ja-Antwort"
@@ -748,14 +712,6 @@
"@permissions": {
"description": "Beschriftung der Berechtigungen"
},
"passwordSaved": "Passwort gespeichert",
"@passwordSaved": {
"description": "Beschriftung Passwort gespeichert"
},
"locationColon": "Standort:",
"@locationColon": {
"description": "Überschrift des Standortbereichs"
},
"telemetry": "Telemetrie",
"@telemetry": {
"description": "Überschrift des Telemetrie-Bereichs"
@@ -784,19 +740,6 @@
"@gpsTelemetry": {
"description": "GPS aus Telemetrie-Beschriftung"
},
"updated": "Aktualisiert",
"@updated": {
"description": "Beschriftung des Aktualisierungszeitstempels"
},
"pathResetInfo": "Pfad zurückgesetzt für {name}. Nächste Nachricht findet eine neue Route.",
"@pathResetInfo": {
"description": "Infonachricht nach Pfad-Reset",
"placeholders": {
"name": {
"type": "String"
}
}
},
"reLoginToRoom": "Erneut bei Raum anmelden",
"@reLoginToRoom": {
"description": "Schaltfläche zur erneuten Anmeldung beim Raum"
@@ -1114,10 +1057,6 @@
"@frequencyMHz": {
"description": "Beschriftung für Funkfrequenzfeld"
},
"frequencyExample": "z.B. 869.618",
"@frequencyExample": {
"description": "Hilfetext-Beispiel für Frequenz"
},
"bandwidth": "Bandbreite",
"@bandwidth": {
"description": "Beschriftung für Bandbreiten-Dropdown"
@@ -1221,47 +1160,6 @@
"@trailControls": {
"description": "Tooltip für Verlaufssteuerung"
},
"contactTrails": "Kontaktverläufe",
"@contactTrails": {
"description": "Überschrift des Bereichs für Kontaktverläufe"
},
"showAllContactTrails": "Alle Kontaktverläufe anzeigen",
"@showAllContactTrails": {
"description": "Beschriftung zum Umschalten der Anzeige aller Kontaktverläufe"
},
"noContactsWithLocationHistory": "Keine Kontakte mit Standortverlauf",
"@noContactsWithLocationHistory": {
"description": "Untertitel, wenn keine Kontakte mit Verläufen vorhanden sind"
},
"showingTrailsForContacts": "Verläufe für {count} Kontakte anzeigen",
"@showingTrailsForContacts": {
"description": "Untertitel mit Anzahl der Kontakte mit Verläufen",
"placeholders": {
"count": {
"type": "int"
}
}
},
"individualContactTrails": "Einzelne Kontaktverläufe",
"@individualContactTrails": {
"description": "Titel des Erweiterungselements für einzelne Kontaktverläufe"
},
"deviceInformation": "Geräteinformationen",
"@deviceInformation": {
"description": "Überschrift des Geräteinformationen-Bereichs"
},
"bleName": "BLE-Name",
"@bleName": {
"description": "Beschriftung des Bluetooth-Low-Energy-Gerätenamens"
},
"meshName": "Mesh-Name",
"@meshName": {
"description": "Beschriftung des Mesh-Netzwerknamens"
},
"notSet": "Nicht festgelegt",
"@notSet": {
"description": "Beschriftung, wenn ein Wert nicht festgelegt ist"
},
"model": "Modell",
"@model": {
"description": "Beschriftung des Gerätemodells"
@@ -1270,14 +1168,6 @@
"@version": {
"description": "Beschriftung der Version"
},
"buildDate": "Build-Datum",
"@buildDate": {
"description": "Beschriftung des Firmware-Build-Datums"
},
"firmware": "Firmware",
"@firmware": {
"description": "Beschriftung der Firmware"
},
"maxContacts": "Max. Kontakte",
"@maxContacts": {
"description": "Beschriftung der maximalen Kontaktkapazität"
@@ -1290,26 +1180,6 @@
"@publicInfo": {
"description": "Überschrift des öffentlichen Informationen-Bereichs"
},
"meshNetworkName": "Mesh-Netzwerkname",
"@meshNetworkName": {
"description": "Beschriftung des Mesh-Netzwerknamens-Felds"
},
"nameBroadcastInMesh": "Name, der in Mesh-Sendungen übertragen wird",
"@nameBroadcastInMesh": {
"description": "Hilfetext für Mesh-Netzwerknamens-Feld"
},
"telemetryAndLocationSharing": "Telemetrie & Standortfreigabe",
"@telemetryAndLocationSharing": {
"description": "Beschriftung des Telemetrie- und Standortfreigabe-Umschalters"
},
"lat": "Breite",
"@lat": {
"description": "Beschriftung des Breitengradfelds (Kurzform)"
},
"lon": "Lange",
"@lon": {
"description": "Beschriftung des Längengradfelds (Kurzform)"
},
"useCurrentLocation": "Aktuellen Standort verwenden",
"@useCurrentLocation": {
"description": "Tooltip für Schaltfläche zum Verwenden des aktuellen Standorts"
@@ -1339,15 +1209,6 @@
}
}
},
"copiedToClipboardShort": "{label} in Zwischenablage kopiert",
"@copiedToClipboardShort": {
"description": "Kurze Erfolgsmeldung beim Kopieren in die Zwischenablage",
"placeholders": {
"label": {
"type": "String"
}
}
},
"failedToSave": "Fehler beim Speichern: {error}",
"@failedToSave": {
"description": "Allgemeine Fehlermeldung für Speicherfehler",
@@ -1367,7 +1228,6 @@
}
},
"sarTemplates": "SAR-Vorlagen",
"manageSarTemplates": "SAR-Vorlagen verwalten",
"addTemplate": "Vorlage hinzufügen",
"editTemplate": "Vorlage bearbeiten",
"deleteTemplate": "Vorlage löschen",
@@ -1395,7 +1255,6 @@
"noTemplates": "Keine Vorlagen verfugbar",
"tapAddToCreate": "Tippen Sie auf +, um Ihre erste Vorlage zu erstellen",
"ok": "OK",
"permissionsSection": "Berechtigungen",
"locationPermission": "Standortberechtigung",
"checking": "Überprüfen...",
"locationPermissionGrantedAlways": "Erteilt (Immer)",
@@ -1535,28 +1394,6 @@
"wizardNext": "Weiter",
"wizardGetStarted": "Loslegen",
"wizardWelcomeTitle": "Willkommen bei MeshCore SAR",
"wizardWelcomeDescription": "Ein leistungsstarkes Offline-Kommunikationstool für Such- und Rettungseinsätze. Verbinden Sie sich mit Ihrem Team über Mesh-Funktechnologie, wenn herkömmliche Netzwerke nicht verfügbar sind.",
"wizardConnectingTitle": "Verbindung zum Radio",
"wizardConnectingDescription": "Verbinden Sie Ihr Smartphone über Bluetooth mit einem MeshCore-Funkgerät, um offline zu kommunizieren.",
"wizardConnectingFeature1": "Nach MeshCore-Geräten in der Nähe suchen",
"wizardConnectingFeature2": "Mit Ihrem Funkgerät über Bluetooth koppeln",
"wizardConnectingFeature3": "Funktioniert vollständig offline - kein Internet erforderlich",
"wizardChannelTitle": "Kanäle",
"wizardChannelDescription": "Senden Sie Nachrichten an alle auf einem Kanal, perfekt für teamweite Ankündigungen und Koordination.",
"wizardChannelFeature1": "Öffentlicher Kanal für allgemeine Teamkommunikation",
"wizardChannelFeature2": "Erstellen Sie benutzerdefinierte Kanäle für bestimmte Gruppen",
"wizardChannelFeature3": "Nachrichten werden automatisch über das Mesh weitergeleitet",
"wizardContactsTitle": "Kontakte",
"wizardContactsDescription": "Ihre Teammitglieder erscheinen automatisch, wenn sie dem Mesh-Netzwerk beitreten. Senden Sie ihnen direkte Nachrichten oder sehen Sie ihren Standort.",
"wizardContactsFeature1": "Kontakte werden automatisch erkannt",
"wizardContactsFeature2": "Private Direktnachrichten senden",
"wizardContactsFeature3": "Batteriestand und letzte Aktivität anzeigen",
"wizardMapTitle": "Karte & Standort",
"wizardMapDescription": "Verfolgen Sie Ihr Team in Echtzeit und markieren Sie wichtige Standorte für Such- und Rettungseinsätze.",
"wizardMapFeature1": "SAR-Markierungen für gefundene Personen, Feuer und Sammelstellen",
"wizardMapFeature2": "GPS-Verfolgung von Teammitgliedern in Echtzeit",
"wizardMapFeature3": "Offline-Karten für entlegene Gebiete herunterladen",
"wizardMapFeature4": "Formen zeichnen und taktische Informationen teilen",
"viewWelcomeTutorial": "Willkommens-Tutorial ansehen",
"allTeamContacts": "Alle Team-Kontakte",
"directMessagesInfo": "Direktnachrichten mit Bestätigungen. An {count} Teammitglieder gesendet.",
@@ -1662,29 +1499,10 @@
"posttxDelay": "Sendeverzögerung",
"bandpass": "Bandpass",
"bandpassFilterVoice": "Bandpassfilter Sprache",
"frequency": "Frequenz",
"australia": "Australien",
"australiaNarrow": "Australien (Schmal)",
"australiaQld": "Australien: QLD",
"australiaSaWa": "Australien: SA, WA",
"newZealand": "Neuseeland",
"newZealandNarrow": "Neuseeland (Schmal)",
"switzerland": "Schweiz",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "Tschechien (Schmal)",
"eu433mhzLongRange": "EU 433MHz (Langstrecke)",
"euukDeprecated": "EU/UK (Veraltet)",
"euukNarrow": "EU/UK (Schmal)",
"usacanadaRecommended": "USA/Kanada (Empfohlen)",
"vietnamDeprecated": "Vietnam (Veraltet)",
"vietnamNarrow": "Vietnam (Schmal)",
"active": "Aktiv",
"addContact": "Kontakt hinzufügen",
"all": "Alle",
"autoResolve": "Automatisch auflösen",
"clearAllLabel": "Alle löschen",
"clearRelays": "Relais löschen",
"clearFilters": "Filter löschen",
"clearRoute": "Route löschen",
"clearMessages": "Nachrichten löschen",
@@ -1709,17 +1527,22 @@
"resolveAll": "Alle auflösen",
"send": "Senden",
"sendAnyway": "Trotzdem senden",
"autoSend": "Automatisch senden",
"autoSendDescription": "Über aktuellen Pfad senden.",
"sendDirect": "Direkt senden",
"sendDirectDescription": "Direkt an diesen Kontakt senden.",
"sendFlood": "Per Flutung senden",
"sendFloodDescription": "Über alle Repeater senden.",
"share": "Teilen",
"shareContact": "Kontakt teilen",
"trace": "Trace",
"use": "Verwenden",
"useSelectedFrequency": "Ausgewählte Frequenz verwenden",
"discovery": "Erkennung",
"discoverRepeaters": "Repeater entdecken",
"discoverSensors": "Sensoren entdecken",
"repeaterDiscoverySent": "Repeater-Erkennung gesendet",
"sensorDiscoverySent": "Sensor-Erkennung gesendet",
"clearedPendingDiscoveries": "Ausstehende Entdeckungen gelöscht.",
"postConnectDiscoveryTitle": "Repeater jetzt entdecken?",
"postConnectDiscoveryDescription": "Repeater-Erkennung direkt nach dem Verbinden ausführen, damit Sie nahegelegene MeshCore-Knoten sehen und schneller zu Ihrem Netzwerk hinzufügen können.",
"autoDiscovery": "Automatische Erkennung",
"enableAutomaticAdding": "Automatisches Hinzufügen aktivieren",
"autoaddRepeaters": "Repeater automatisch hinzufügen",
@@ -1736,7 +1559,6 @@
"gpsModule": "GPS-Modul",
"liveTraffic": "Live-Verkehr",
"repeatersMap": "Repeater-Karte",
"spectrumScan": "Spektrum-Scan",
"blePacketLogs": "BLE-Paketprotokolle",
"onlineTraceDatabase": "Online-Trace-Datenbank",
"routePathByteSize": "Pfad-Bytegröße",
@@ -1748,7 +1570,6 @@
"disableContacts": "Kontakte deaktivieren",
"enableSensorsTab": "Sensoren-Tab aktivieren",
"enableProfiles": "Profile aktivieren",
"autoRouteRotation": "Automatische Routenrotation",
"nearestRepeaterFallback": "Nächster Repeater als Fallback",
"deleteAllStoredMessageHistory": "Gesamten Nachrichtenverlauf löschen",
"messageFontSize": "Nachrichtenschriftgröße",
@@ -1756,18 +1577,13 @@
"showMapDebugInfo": "Karten-Debug-Info anzeigen",
"openMapInFullscreen": "Karte im Vollbild öffnen",
"showSarMarkersLabel": "SAR-Markierungen anzeigen",
"displaySarMarkersOnTheMainMap": "SAR-Markierungen auf der Hauptkarte anzeigen",
"showAllContactTrailsLabel": "Alle Kontaktpfade anzeigen",
"hideRepeatersOnMap": "Repeater auf Karte ausblenden",
"setMapScale": "Kartenmaßstab einstellen",
"customMapScaleSaved": "Benutzerdefinierter Kartenmaßstab gespeichert",
"voiceBitrate": "Sprach-Bitrate",
"voiceCompressor": "Sprachkompressor",
"balancesQuietAndLoudSpeechLevels": "Gleicht leise und laute Sprache aus",
"voiceLimiter": "Sprach-Limiter",
"preventsClippingPeaksBeforeEncoding": "Verhindert Clipping-Spitzen vor der Kodierung",
"micAutoGain": "Mikrofon-Automatik",
"letsTheRecorderAdjustInputLevel": "Lässt den Rekorder den Eingangspegel anpassen",
"echoCancellation": "Echounterdrückung",
"noiseSuppression": "Rauschunterdrückung",
"trimSilenceInVoiceMessages": "Stille in Sprachnachrichten trimmen",
@@ -1809,13 +1625,10 @@
"staging": "Sammelpunkt",
"object": "Objekt",
"quiet": "Ruhig",
"moderate": "Mäßig",
"busy": "Beschäftigt",
"spectrumScanReturnedNoCandidateFrequencies": "Spektrum-Scan hat keine Kandidatenfrequenzen gefunden",
"searchMessages": "Nachrichten suchen",
"sendImageFromGallery": "Bild aus Galerie senden",
"takePhoto": "Foto aufnehmen",
"dmOnly": "Nur Direktnachricht",
"allMessages": "Alle Nachrichten",
"sendToPublicChannel": "An öffentlichen Kanal senden?",
"selectMarkerTypeAndDestination": "Markierungstyp und Ziel auswählen",
@@ -1834,8 +1647,6 @@
"wipeDevice": "Gerät löschen",
"destructiveDeviceActions": "Destruktive Geräteaktionen.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Wählen Sie eine Voreinstellung oder passen Sie die Funkeinstellungen an.",
"chooseTheNameAndLocationThisDeviceShares": "Wählen Sie den Namen und Standort, den dieses Gerät teilt.",
"availableSpaceOnThisDevice": "Verfügbarer Speicherplatz auf diesem Gerät.",
"used": "Belegt",
"total": "Gesamt",
"renameValue": "Wert umbenennen",
@@ -1853,8 +1664,6 @@
"onlineOnly": "Nur online",
"inBoth": "In beiden",
"source": "Quelle",
"manualRouteEdit": "Manuelle Routenbearbeitung",
"observedMeshRoute": "Beobachtete Mesh-Route",
"allMessagesCleared": "Alle Nachrichten gelöscht",
"onlineTraceDatabaseCleared": "Online-Trace-Datenbank gelöscht",
"packetLogsCleared": "Paketprotokolle gelöscht",
@@ -1885,5 +1694,186 @@
"captured": "Erfasst",
"size": "Größe",
"noCustomChannelsToClear": "Keine benutzerdefinierten Kanäle zum Löschen.",
"noDeviceContactsToClear": "Keine Gerätekontakte zum Löschen."
"noDeviceContactsToClear": "Keine Gerätekontakte zum Löschen.",
"aToZ": "A-Z",
"add": "Hinzufügen",
"addAdditionalDetails": "Zusätzliche Details hinzufügen",
"addPeer": "Peer hinzufügen",
"addPeerManually": "Peer manuell hinzufügen",
"addedToContacts": "{name} zu Kontakten hinzugefügt",
"addedToSensors": "{name} zu Sensoren hinzugefügt",
"allowAll": "Alle zulassen",
"anonymousRxStats": "Anonyme RX-Statistiken",
"appearance": "Erscheinungsbild",
"ascii": "ASCII",
"baseTelemetry": "Basistelemetrie",
"blePin": "BLE PIN",
"buzzerAlerts": "Summer-Alarme",
"cancelImageReceive": "Bildempfang abbrechen",
"choose": "Auswählen",
"chooseWhichTabsAndContactSectionsToShow": "Wähle, welche Tabs und Kontaktbereiche angezeigt werden",
"clearCache": "Cache leeren",
"clearHistory": "Verlauf löschen",
"clearLiveView": "Live-Ansicht leeren",
"clearLogs": "Protokolle löschen",
"clearOfflineCache": "Offline-Cache leeren?",
"clearOverlay": "Overlay löschen",
"clearRouteOnlyAfterAllRetriesFail": "Route erst nach allen fehlgeschlagenen Versuchen löschen",
"codecBitrateAndAudioProcessing": "Codec, Bitrate und Audioverarbeitung",
"connectByIpAddress": "Über IP-Adresse verbinden",
"controlWhichAlertsYouReceive": "Steuere, welche Benachrichtigungen du erhältst",
"copyFullHex": "Vollständigen Hex kopieren",
"copyRawResponse": "Rohantwort kopieren",
"couldNotLoadMetHistory": "MET-Verlauf konnte nicht geladen werden",
"customizeSensor": "{name} anpassen",
"data": "Daten",
"deleteGroup": "Gruppe löschen",
"deleteStyleConfirm": "{name} löschen?",
"deleteThisDrawing": "Diese(s) {type} löschen?",
"deny": "Ablehnen",
"deviceInfo": "Geräteinformationen",
"deviceSettings": "Geräteeinstellungen",
"deviceTimeSynced": "Gerätezeit mit diesem Telefon synchronisiert.",
"disableFastGpsPublishing": "Schnelle GPS-Veröffentlichung deaktivieren",
"displayMarkersAndTraceDatabase": "Anzeige, Markierungen und Trace-Datenbank",
"download": "Herunterladen",
"environmentalTelemetry": "Umwelttelemetrie",
"errorCheckingForUpdates": "Fehler bei der Suche nach Updates: {error}",
"errorGeneric": "Fehler: {error}",
"errorOpeningDownload": "Fehler beim Öffnen des Downloads: {error}",
"exportLogs": "Protokolle exportieren",
"extraAggressiveAvifCompression": "Extra-aggressive AVIF-Komprimierung",
"failedToAddContact": "Kontakt konnte nicht hinzugefügt werden: {error}",
"failedToAddName": "{name} konnte nicht hinzugefügt werden: {error}",
"failedToClearChannels": "Kanäle konnten nicht gelöscht werden: {error}",
"failedToClearContacts": "Kontakte konnten nicht gelöscht werden: {error}",
"failedToConnectViaSerial": "Verbindung über seriell fehlgeschlagen",
"failedToLoadPreviewImage": "Vorschaubild konnte nicht geladen werden: {error}",
"failedToLoadTrace": "Trace konnte nicht geladen werden: {error}",
"failedToSendSarMarker": "SAR-Markierung konnte nicht gesendet werden: {error}",
"failedToSetBuzzerMode": "Summermodus konnte nicht gesetzt werden: {error}",
"failedToSetGpsMode": "GPS-Modus konnte nicht gesetzt werden: {error}",
"failedToSyncDeviceTime": "Gerätezeit konnte nicht synchronisiert werden: {error}",
"failedToWipeDeviceData": "Gerätedaten konnten nicht gelöscht werden: {error}",
"fastGpsTargetChannel": "Schneller GPS-Zielkanal",
"fastUpdatesThresholdsAndPermissions": "Schnelle Updates, Schwellenwerte und Berechtigungen",
"fetchingCatalog": "Katalog wird geladen...",
"fifteenMinutes": "15 Minuten",
"fifteenSeconds": "15 Sekunden",
"filterByDirection": "Nach Richtung filtern",
"finish": "Fertig",
"fiveMinutes": "5 Minuten",
"fiveSeconds": "5 Sekunden",
"fix": "Fix",
"gps": "GPS",
"gpsAdvertPolicy": "GPS-Anzeigenrichtlinie",
"gpsAndLocation": "GPS & Standort",
"gpsDiagnostics": "GPS-Diagnose",
"gpsSection": "GPS",
"help": "Hilfe",
"hex": "Hex",
"hidden": "Ausgeblendet",
"howTheRadioAutoAddsDiscoveredNodes": "Wie das Funkgerät entdeckte Knoten automatisch hinzufügt.",
"images": "Bilder",
"importProfile": "Profil importieren",
"lastFix": "Letzter Fix",
"latest": "Neueste",
"latitude": "Breitengrad",
"loadASavedRegion": "Gespeicherte Region laden",
"loading": "Laden",
"locationSharingHardwareAndUpdateInterval": "Standortfreigabe, Hardware und Aktualisierungsintervall.",
"locationTelemetry": "Standorttelemetrie",
"lockDestination": "Ziel sperren",
"longitude": "Längengrad",
"mapStyle": "Kartenstil",
"max": "Max",
"maxZoom": "Max. Zoom",
"messaging": "Nachrichten",
"metHistory": "MET-Verlauf",
"min": "Min",
"minZoom": "Min. Zoom",
"moveDown": "Nach unten",
"moveUp": "Nach oben",
"multiAckMode": "Multi-ACK-Modus",
"multiDeviceWorkspaceManagement": "Mehrgeräte-Arbeitsbereichsverwaltung",
"myLocation": "Mein Standort",
"nameAndTelemetryShared": "Name und Telemetrie werden mit anderen Geräten geteilt.",
"newVersionAvailable": "Neue Version: {version}",
"newer": "Neuer",
"noNeighboursFound": "Keine Nachbarn gefunden",
"noServersFound": "Keine Server gefunden",
"notSet": "Nicht gesetzt",
"notifications": "Benachrichtigungen",
"offZeroSeconds": "Aus (0 s)",
"offlineMaps": "Offlinekarten",
"older": "Älter",
"oneByteMode0": "1 Byte (Modus 0)",
"oneHour": "1 Stunde",
"oneMinute": "1 Minute",
"openPacketLogs": "Paketprotokolle öffnen",
"packetTypeHelp": "Pakettyp-Hilfe",
"pasteShareLinkOrHexAdvert": "Teilen-Link oder Hex-Anzeige einfügen",
"pathHash": "Pfad-Hash",
"pathSize": "Pfadgröße",
"ping": "Ping",
"polygon": "Polygon",
"rawResponseCopied": "Rohantwort kopiert",
"resendThroughNearestRepeaterOnFailure": "Bei Fehler über nächsten Repeater erneut senden",
"resolveContact": "Kontakt auflösen",
"routingRetriesAndDestinationLock": "Routing, Wiederholungsversuche und Zielsperre",
"samples": "Proben",
"satellites": "Satelliten",
"saving": "Speichern...",
"search": "Suchen",
"searchByNameOrToken": "Nach Name oder Token suchen",
"searchDiscoveredNodes": "Entdeckte Knoten suchen",
"searchInCurrentFilter": "Im aktuellen Filter suchen",
"searchLogs": "Protokolle durchsuchen...",
"searchSensors": "Sensoren suchen",
"searchThisSection": "Diesen Bereich durchsuchen",
"selectedRelays": "Ausgewählte Relais",
"sensor": "Sensor",
"serialError": "Serieller Fehler: {error}",
"setPathForContact": "Pfad für {name} festlegen",
"shareLiveGps": "Live-GPS teilen",
"shareMyTiles": "Meine Kacheln teilen",
"sharingLocationFromPhone": "Standort auf diesem Kanal vom Telefon wird geteilt.",
"sharingLocationFromRadio": "Standort auf diesem Kanal vom Funkgerät wird geteilt.",
"signalDbm": "Signal {rssi} dBm",
"sizeCompressionAndPreview": "Größe, Komprimierung und Vorschau",
"skip": "Überspringen",
"smallerFileSize": "Kleinere Dateigröße",
"sort": "Sortieren",
"stoppedSharingLocation": "Standortfreigabe auf diesem Kanal beendet.",
"suppressNotificationsWhileInForeground": "Benachrichtigungen im Vordergrund unterdrücken",
"tabsAndNavigation": "Tabs & Navigation",
"templatesAndTutorials": "Vorlagen und Anleitungen",
"tenMinutes": "10 Minuten",
"tenSeconds": "10 Sekunden",
"testSendUpdate": "Testupdate senden",
"themeLanguageAndDisplayPreferences": "Design, Sprache und Anzeigeeinstellungen",
"thirtyMinutes": "30 Minuten",
"thirtySeconds": "30 Sekunden",
"threeBytesMode2": "3 Bytes (Modus 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Verkehrsstatistiken, Nachrichtenverlauf und Entwicklertools",
"twoBytesMode1": "2 Bytes (Modus 1)",
"twoMinutes": "2 Minuten",
"undo": "Rückgängig",
"updated": "Aktualisiert",
"useContactFlags": "Kontakt-Flags verwenden",
"useSavedCoordinates": "Gespeicherte Koordinaten verwenden",
"view": "Ansicht",
"viewNeighbours": "Nachbarn anzeigen",
"viewPublicStats": "Öffentliche Statistiken anzeigen",
"setRegionScope": "Regionsbereich festlegen",
"regionScope": "Regionsbereich",
"regionScopeNone": "Keiner (global)",
"clearRegionScope": "Bereich löschen",
"regionScopeWarning": "Nur Repeater, die diese Region zulassen, werden weiterleiten.",
"discoverRegions": "Von Repeatern entdecken",
"discoveringRegions": "Regionen werden entdeckt...",
"enterRegionName": "Regionsname (z.B. auckland)",
"noRegionsFound": "Keine Regionen auf diesem Repeater gefunden.",
"regionScopeSet": "Regionsbereich auf {name} gesetzt",
"regionScopeCleared": "Regionsbereich gelöscht"
}

View File

@@ -36,10 +36,6 @@
"@scanAgain": {
"description": "Button to restart BLE scanning"
},
"tapToConnect": "Πατήστε για σύνδεση",
"@tapToConnect": {
"description": "Subtitle text for device in scan list"
},
"deviceNotConnected": "Η συσκευή δεν είναι συνδεδεμένη",
"@deviceNotConnected": {
"description": "Error message when device is not connected"
@@ -133,18 +129,10 @@
"@showRxTxIndicators": {
"description": "Setting to show RX/TX indicators"
},
"displayPacketActivity": "Εμφάνιση ενδείξεων δραστηριότητας πακέτων στην επάνω μπάρα",
"@displayPacketActivity": {
"description": "Description for RX/TX indicators setting"
},
"disableMap": "Απενεργοποίηση χάρτη",
"@disableMap": {
"description": "Setting to disable the map tab"
},
"disableMapDescription": "Απόκρυψη της καρτέλας χάρτη για μείωση κατανάλωσης μπαταρίας",
"@disableMapDescription": {
"description": "Description for disable map setting"
},
"language": "Γλώσσα",
"@language": {
"description": "Language setting label"
@@ -173,10 +161,6 @@
"@appVersion": {
"description": "App version label"
},
"appName": "Όνομα εφαρμογής",
"@appName": {
"description": "App name label"
},
"aboutMeshCoreSar": "Σχετικά με το MeshCore SAR",
"@aboutMeshCoreSar": {
"description": "About dialog title"
@@ -308,10 +292,6 @@
"@resetPath": {
"description": "Action to reset contact path for re-routing"
},
"publicKeyCopied": "Το δημόσιο κλειδί αντιγράφηκε στο πρόχειρο",
"@publicKeyCopied": {
"description": "Success message when public key is copied"
},
"copiedToClipboard": "Το {label} αντιγράφηκε στο πρόχειρο",
"@copiedToClipboard": {
"description": "Success message when a value is copied to clipboard",
@@ -720,22 +700,6 @@
"@lastSeen": {
"description": "Last seen label"
},
"roomStatus": "Κατάσταση δωματίου",
"@roomStatus": {
"description": "Room status section header"
},
"loginStatus": "Κατάσταση σύνδεσης",
"@loginStatus": {
"description": "Login status label"
},
"notLoggedIn": "Μη συνδεδεμένος",
"@notLoggedIn": {
"description": "Not logged in status"
},
"adminAccess": "Πρόσβαση διαχειριστή",
"@adminAccess": {
"description": "Admin access label"
},
"yes": "Ναι",
"@yes": {
"description": "Yes answer"
@@ -748,14 +712,6 @@
"@permissions": {
"description": "Permissions label"
},
"passwordSaved": "Ο κωδικός αποθηκεύτηκε",
"@passwordSaved": {
"description": "Password saved label"
},
"locationColon": "Τοποθεσία:",
"@locationColon": {
"description": "Location section header"
},
"telemetry": "Τηλεμετρία",
"@telemetry": {
"description": "Telemetry section header"
@@ -784,19 +740,6 @@
"@gpsTelemetry": {
"description": "GPS from telemetry label"
},
"updated": "Ενημερώθηκε",
"@updated": {
"description": "Updated timestamp label"
},
"pathResetInfo": "Η διαδρομή για το {name} επαναφέρθηκε. Το επόμενο μήνυμα θα βρει νέα διαδρομή.",
"@pathResetInfo": {
"description": "Info message after path reset",
"placeholders": {
"name": {
"type": "String"
}
}
},
"reLoginToRoom": "Επανασύνδεση στο δωμάτιο",
"@reLoginToRoom": {
"description": "Button to re-login to room"
@@ -1158,10 +1101,6 @@
"@frequencyMHz": {
"description": "Label for radio frequency field"
},
"frequencyExample": "π.χ. 869.618",
"@frequencyExample": {
"description": "Helper text example for frequency"
},
"bandwidth": "Εύρος ζώνης",
"@bandwidth": {
"description": "Label for bandwidth dropdown"
@@ -1265,47 +1204,6 @@
"@trailControls": {
"description": "Trail controls tooltip"
},
"contactTrails": "Ίχνη επαφών",
"@contactTrails": {
"description": "Contact trails section header"
},
"showAllContactTrails": "Εμφάνιση όλων των ιχνών επαφών",
"@showAllContactTrails": {
"description": "Toggle label to show all contact trails"
},
"noContactsWithLocationHistory": "Δεν υπάρχουν επαφές με ιστορικό τοποθεσίας",
"@noContactsWithLocationHistory": {
"description": "Subtitle when no contacts have trails"
},
"showingTrailsForContacts": "Εμφανίζονται ίχνη για {count} επαφές",
"@showingTrailsForContacts": {
"description": "Subtitle showing number of contacts with trails",
"placeholders": {
"count": {
"type": "int"
}
}
},
"individualContactTrails": "Μεμονωμένα ίχνη επαφών",
"@individualContactTrails": {
"description": "Expansion tile title for individual contact trails"
},
"deviceInformation": "Πληροφορίες συσκευής",
"@deviceInformation": {
"description": "Device information section header"
},
"bleName": "Όνομα BLE",
"@bleName": {
"description": "Bluetooth Low Energy device name label"
},
"meshName": "Όνομα mesh",
"@meshName": {
"description": "Mesh network name label"
},
"notSet": "Δεν έχει οριστεί",
"@notSet": {
"description": "Label when a value is not set"
},
"model": "Μοντέλο",
"@model": {
"description": "Device model label"
@@ -1314,14 +1212,6 @@
"@version": {
"description": "Version label"
},
"buildDate": "Ημερομηνία build",
"@buildDate": {
"description": "Firmware build date label"
},
"firmware": "Υλικολογισμικό",
"@firmware": {
"description": "Firmware label"
},
"maxContacts": "Μέγιστες επαφές",
"@maxContacts": {
"description": "Maximum contacts capacity label"
@@ -1334,26 +1224,6 @@
"@publicInfo": {
"description": "Public information section header"
},
"meshNetworkName": "Όνομα δικτύου mesh",
"@meshNetworkName": {
"description": "Mesh network name field label"
},
"nameBroadcastInMesh": "Όνομα που μεταδίδεται στις διαφημίσεις mesh",
"@nameBroadcastInMesh": {
"description": "Helper text for mesh network name field"
},
"telemetryAndLocationSharing": "Τηλεμετρία και κοινοποίηση τοποθεσίας",
"@telemetryAndLocationSharing": {
"description": "Telemetry and location sharing toggle label"
},
"lat": "Πλάτος",
"@lat": {
"description": "Latitude field label (short form)"
},
"lon": "Μήκος",
"@lon": {
"description": "Longitude field label (short form)"
},
"useCurrentLocation": "Χρήση τρέχουσας τοποθεσίας",
"@useCurrentLocation": {
"description": "Tooltip for use current location button"
@@ -1383,15 +1253,6 @@
}
}
},
"copiedToClipboardShort": "Το {label} αντιγράφηκε στο πρόχειρο",
"@copiedToClipboardShort": {
"description": "Short success message when copying to clipboard",
"placeholders": {
"label": {
"type": "String"
}
}
},
"failedToSave": "Αποτυχία αποθήκευσης: {error}",
"@failedToSave": {
"description": "Generic error message for save failures",
@@ -1414,10 +1275,6 @@
"@sarTemplates": {
"description": "SAR templates menu title"
},
"manageSarTemplates": "Διαχείριση προτύπων SAR",
"@manageSarTemplates": {
"description": "Subtitle for SAR templates settings"
},
"addTemplate": "Προσθήκη προτύπου",
"@addTemplate": {
"description": "Button to add new SAR template"
@@ -1541,10 +1398,6 @@
"@ok": {
"description": "OK button label"
},
"permissionsSection": "Δικαιώματα",
"@permissionsSection": {
"description": "Permissions section header"
},
"locationPermission": "Άδεια τοποθεσίας",
"@locationPermission": {
"description": "Location permission label"
@@ -2109,94 +1962,6 @@
"@wizardWelcomeTitle": {
"description": "Welcome wizard first page title"
},
"wizardWelcomeDescription": "Ένα ισχυρό εργαλείο επικοινωνίας εκτός δικτύου για επιχειρήσεις έρευνας και διάσωσης. Συνδεθείτε με την ομάδα σας χρησιμοποιώντας τεχνολογία mesh radio όταν τα παραδοσιακά δίκτυα δεν είναι διαθέσιμα.",
"@wizardWelcomeDescription": {
"description": "Welcome wizard first page description"
},
"wizardConnectingTitle": "Σύνδεση στο radio σας",
"@wizardConnectingTitle": {
"description": "Wizard connecting page title"
},
"wizardConnectingDescription": "Συνδέστε το smartphone σας με μια συσκευή MeshCore radio μέσω Bluetooth για να αρχίσετε να επικοινωνείτε εκτός δικτύου.",
"@wizardConnectingDescription": {
"description": "Wizard connecting page description"
},
"wizardConnectingFeature1": "Σάρωση για κοντινές συσκευές MeshCore",
"@wizardConnectingFeature1": {
"description": "Wizard connecting feature 1"
},
"wizardConnectingFeature2": "Σύζευξη με το radio σας μέσω Bluetooth",
"@wizardConnectingFeature2": {
"description": "Wizard connecting feature 2"
},
"wizardConnectingFeature3": "Λειτουργεί πλήρως εκτός σύνδεσης - δεν απαιτείται internet",
"@wizardConnectingFeature3": {
"description": "Wizard connecting feature 3"
},
"wizardChannelTitle": "Κανάλια",
"@wizardChannelTitle": {
"description": "Wizard channel page title"
},
"wizardChannelDescription": "Μεταδώστε μηνύματα σε όλους σε ένα κανάλι, ιδανικό για ανακοινώσεις και συντονισμό σε όλη την ομάδα.",
"@wizardChannelDescription": {
"description": "Wizard channel page description"
},
"wizardChannelFeature1": "Δημόσιο κανάλι για γενική επικοινωνία ομάδας",
"@wizardChannelFeature1": {
"description": "Wizard channel feature 1"
},
"wizardChannelFeature2": "Δημιουργία προσαρμοσμένων καναλιών για συγκεκριμένες ομάδες",
"@wizardChannelFeature2": {
"description": "Wizard channel feature 2"
},
"wizardChannelFeature3": "Τα μηνύματα αναμεταδίδονται αυτόματα από το mesh",
"@wizardChannelFeature3": {
"description": "Wizard channel feature 3"
},
"wizardContactsTitle": "Επαφές",
"@wizardContactsTitle": {
"description": "Wizard contacts page title"
},
"wizardContactsDescription": "Τα μέλη της ομάδας σας εμφανίζονται αυτόματα καθώς συνδέονται στο δίκτυο mesh. Στείλτε τους άμεσα μηνύματα ή δείτε την τοποθεσία τους.",
"@wizardContactsDescription": {
"description": "Wizard contacts page description"
},
"wizardContactsFeature1": "Οι επαφές εντοπίζονται αυτόματα",
"@wizardContactsFeature1": {
"description": "Wizard contacts feature 1"
},
"wizardContactsFeature2": "Αποστολή ιδιωτικών άμεσων μηνυμάτων",
"@wizardContactsFeature2": {
"description": "Wizard contacts feature 2"
},
"wizardContactsFeature3": "Προβολή επιπέδου μπαταρίας και χρόνου τελευταίας εμφάνισης",
"@wizardContactsFeature3": {
"description": "Wizard contacts feature 3"
},
"wizardMapTitle": "Χάρτης και τοποθεσία",
"@wizardMapTitle": {
"description": "Wizard map page title"
},
"wizardMapDescription": "Παρακολουθήστε την ομάδα σας σε πραγματικό χρόνο και σημειώστε σημαντικές τοποθεσίες για επιχειρήσεις έρευνας και διάσωσης.",
"@wizardMapDescription": {
"description": "Wizard map page description"
},
"wizardMapFeature1": "Δείκτες SAR για εντοπισμένα άτομα, φωτιές και σημεία συγκέντρωσης",
"@wizardMapFeature1": {
"description": "Wizard map feature 1"
},
"wizardMapFeature2": "Παρακολούθηση GPS των μελών της ομάδας σε πραγματικό χρόνο",
"@wizardMapFeature2": {
"description": "Wizard map feature 2"
},
"wizardMapFeature3": "Λήψη χαρτών εκτός σύνδεσης για απομακρυσμένες περιοχές",
"@wizardMapFeature3": {
"description": "Wizard map feature 3"
},
"wizardMapFeature4": "Σχεδίαση σχημάτων και κοινοποίηση τακτικών πληροφοριών",
"@wizardMapFeature4": {
"description": "Wizard map feature 4"
},
"viewWelcomeTutorial": "Προβολή οδηγού καλωσορίσματος",
"@viewWelcomeTutorial": {
"description": "Settings option to re-show welcome wizard"
@@ -2327,29 +2092,10 @@
"posttxDelay": "Καθυστέρηση μετά-tx",
"bandpass": "Ζωνοπερατό",
"bandpassFilterVoice": "Ζωνοπερατό φίλτρο φωνής",
"frequency": "Συχνότητα",
"australia": "Αυστραλία",
"australiaNarrow": "Αυστραλία (Στενό)",
"australiaQld": "Αυστραλία: QLD",
"australiaSaWa": "Αυστραλία: SA, WA",
"newZealand": "Νέα Ζηλανδία",
"newZealandNarrow": "Νέα Ζηλανδία (Στενό)",
"switzerland": "Ελβετία",
"portugal433": "Πορτογαλία 433",
"portugal868": "Πορτογαλία 868",
"czechRepublicNarrow": "Τσεχία (Στενό)",
"eu433mhzLongRange": "ΕΕ 433MHz (Μεγάλη Εμβέλεια)",
"euukDeprecated": "ΕΕ/ΗΒ (Παρωχημένο)",
"euukNarrow": "ΕΕ/ΗΒ (Στενό)",
"usacanadaRecommended": "ΗΠΑ/Καναδάς (Προτεινόμενο)",
"vietnamDeprecated": "Βιετνάμ (Παρωχημένο)",
"vietnamNarrow": "Βιετνάμ (Στενό)",
"active": "Ενεργό",
"addContact": "Προσθήκη επαφής",
"all": "Όλα",
"autoResolve": "Αυτόματη επίλυση",
"clearAllLabel": "Εκκαθάριση όλων",
"clearRelays": "Εκκαθάριση αναμεταδοτών",
"clearFilters": "Εκκαθάριση φίλτρων",
"clearRoute": "Εκκαθάριση διαδρομής",
"clearMessages": "Εκκαθάριση μηνυμάτων",
@@ -2375,10 +2121,7 @@
"send": "Αποστολή",
"sendAnyway": "Αποστολή ούτως ή άλλως",
"share": "Κοινοποίηση",
"shareContact": "Κοινοποίηση επαφής",
"trace": "Ίχνος",
"use": "Χρήση",
"useSelectedFrequency": "Χρήση επιλεγμένης συχνότητας",
"discovery": "Ανακάλυψη",
"discoverRepeaters": "Ανακάλυψη αναμεταδοτών",
"discoverSensors": "Ανακάλυψη αισθητήρων",
@@ -2401,7 +2144,6 @@
"gpsModule": "Μονάδα GPS",
"liveTraffic": "Ζωντανή κίνηση",
"repeatersMap": "Χάρτης αναμεταδοτών",
"spectrumScan": "Σάρωση φάσματος",
"blePacketLogs": "Αρχεία πακέτων BLE",
"onlineTraceDatabase": "Βάση δεδομένων ιχνών",
"routePathByteSize": "Μέγεθος διαδρομής σε bytes",
@@ -2413,7 +2155,6 @@
"disableContacts": "Απενεργοποίηση επαφών",
"enableSensorsTab": "Ενεργοποίηση καρτέλας Αισθητήρων",
"enableProfiles": "Ενεργοποίηση προφίλ",
"autoRouteRotation": "Αυτόματη εναλλαγή διαδρομής",
"nearestRepeaterFallback": "Πλησιέστερος αναμεταδότης ως εφεδρεία",
"deleteAllStoredMessageHistory": "Διαγραφή όλου του ιστορικού",
"messageFontSize": "Μέγεθος γραμματοσειράς μηνυμάτων",
@@ -2421,18 +2162,13 @@
"showMapDebugInfo": "Εμφάνιση πληροφοριών αποσφαλμάτωσης",
"openMapInFullscreen": "Άνοιγμα χάρτη σε πλήρη οθόνη",
"showSarMarkersLabel": "Εμφάνιση δεικτών SAR",
"displaySarMarkersOnTheMainMap": "Εμφάνιση δεικτών SAR στον κύριο χάρτη",
"showAllContactTrailsLabel": "Εμφάνιση όλων των ιχνών επαφών",
"hideRepeatersOnMap": "Απόκρυψη αναμεταδοτών στον χάρτη",
"setMapScale": "Ρύθμιση κλίμακας χάρτη",
"customMapScaleSaved": "Προσαρμοσμένη κλίμακα χάρτη αποθηκεύτηκε",
"voiceBitrate": "Ρυθμός bit φωνής",
"voiceCompressor": "Συμπιεστής φωνής",
"balancesQuietAndLoudSpeechLevels": "Εξισορρόπηση χαμηλών και δυνατών επιπέδων",
"voiceLimiter": "Περιοριστής φωνής",
"preventsClippingPeaksBeforeEncoding": "Αποτρέπει κοπή κορυφών πριν την κωδικοποίηση",
"micAutoGain": "Αυτόματη ενίσχυση μικροφώνου",
"letsTheRecorderAdjustInputLevel": "Αφήνει τη συσκευή να ρυθμίσει την ένταση",
"echoCancellation": "Ακύρωση ηχούς",
"noiseSuppression": "Καταστολή θορύβου",
"trimSilenceInVoiceMessages": "Περικοπή σιωπής σε φωνητικά μηνύματα",
@@ -2474,13 +2210,10 @@
"staging": "Χώρος συγκέντρωσης",
"object": "Αντικείμενο",
"quiet": "Ήσυχο",
"moderate": "Μέτριο",
"busy": "Απασχολημένο",
"spectrumScanReturnedNoCandidateFrequencies": "Η σάρωση φάσματος δεν βρήκε υποψήφιες συχνότητες",
"searchMessages": "Αναζήτηση μηνυμάτων",
"sendImageFromGallery": "Αποστολή εικόνας από τη γκαλερί",
"takePhoto": "Λήψη φωτογραφίας",
"dmOnly": "Μόνο απευθείας",
"allMessages": "Όλα τα μηνύματα",
"sendToPublicChannel": "Αποστολή στο δημόσιο κανάλι;",
"selectMarkerTypeAndDestination": "Επιλέξτε τύπο δείκτη και προορισμό",
@@ -2499,8 +2232,6 @@
"wipeDevice": "Διαγραφή συσκευής",
"destructiveDeviceActions": "Καταστροφικές ενέργειες συσκευής.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Επιλέξτε μια προεπιλογή ή ρυθμίστε τις ρυθμίσεις ραδιοφώνου.",
"chooseTheNameAndLocationThisDeviceShares": "Επιλέξτε το όνομα και την τοποθεσία που μοιράζεται αυτή η συσκευή.",
"availableSpaceOnThisDevice": "Διαθέσιμος χώρος σε αυτή τη συσκευή.",
"used": "Χρησιμοποιήθηκε",
"total": "Σύνολο",
"renameValue": "Μετονομασία τιμής",
@@ -2518,8 +2249,6 @@
"onlineOnly": "Μόνο συνδεδεμένοι",
"inBoth": "Σε αμφότερα",
"source": "Πηγή",
"manualRouteEdit": "Χειροκίνητη επεξεργασία διαδρομής",
"observedMeshRoute": "Παρατηρούμενη διαδρομή δικτύου",
"allMessagesCleared": "Όλα τα μηνύματα διαγράφηκαν",
"onlineTraceDatabaseCleared": "Βάση δεδομένων ιχνών εκκαθαρίστηκε",
"packetLogsCleared": "Τα αρχεία πακέτων εκκαθαρίστηκαν",
@@ -2550,5 +2279,194 @@
"captured": "Καταγράφηκε",
"size": "Μέγεθος",
"noCustomChannelsToClear": "Δεν υπάρχουν προσαρμοσμένα κανάλια.",
"noDeviceContactsToClear": "Δεν υπάρχουν επαφές συσκευής."
"noDeviceContactsToClear": "Δεν υπάρχουν επαφές συσκευής.",
"autoSend": "Αυτόματη αποστολή",
"autoSendDescription": "Αποστολή μέσω τρέχουσας διαδρομής.",
"sendDirect": "Άμεση αποστολή",
"sendDirectDescription": "Αποστολή απευθείας σε αυτήν την επαφή.",
"sendFlood": "Αποστολή πλημμύρας",
"sendFloodDescription": "Αποστολή μέσω όλων των αναμεταδοτών.",
"postConnectDiscoveryTitle": "Ανακάλυψη αναμεταδοτών τώρα;",
"postConnectDiscoveryDescription": "Εκτελέστε ανακάλυψη αναμεταδοτών αμέσως μετά τη σύνδεση, ώστε να μπορείτε να δείτε κοντινούς κόμβους MeshCore και να τους προσθέσετε στο δίκτυό σας πιο γρήγορα.",
"aToZ": "A-Z",
"add": "Προσθήκη",
"addAdditionalDetails": "Προσθήκη επιπλέον λεπτομερειών",
"addPeer": "Προσθήκη κόμβου",
"addPeerManually": "Χειροκίνητη προσθήκη κόμβου",
"addedToContacts": "Ο/Η {name} προστέθηκε στις επαφές",
"addedToSensors": "Ο/Η {name} προστέθηκε στους αισθητήρες",
"allowAll": "Επιτρέπονται όλα",
"anonymousRxStats": "Ανώνυμα στατιστικά λήψης",
"appearance": "Εμφάνιση",
"ascii": "ASCII",
"baseTelemetry": "Βασική τηλεμετρία",
"blePin": "BLE PIN",
"buzzerAlerts": "Ειδοποιήσεις βομβητή",
"cancelImageReceive": "Ακύρωση λήψης εικόνας",
"choose": "Επιλογή",
"chooseWhichTabsAndContactSectionsToShow": "Επιλέξτε ποιες καρτέλες και ενότητες επαφών θα εμφανίζονται",
"clearCache": "Εκκαθάριση προσωρινής μνήμης",
"clearHistory": "Εκκαθάριση ιστορικού",
"clearLiveView": "Εκκαθάριση ζωντανής προβολής",
"clearLogs": "Εκκαθάριση αρχείων καταγραφής",
"clearOfflineCache": "Εκκαθάριση προσωρινής μνήμης εκτός σύνδεσης;",
"clearOverlay": "Εκκαθάριση επικάλυψης",
"clearRouteOnlyAfterAllRetriesFail": "Εκκαθάριση διαδρομής μόνο μετά την αποτυχία όλων των επαναπροσπαθειών",
"codecBitrateAndAudioProcessing": "Κωδικοποιητής, ρυθμός bit και επεξεργασία ήχου",
"connectByIpAddress": "Σύνδεση μέσω διεύθυνσης IP",
"controlWhichAlertsYouReceive": "Ελέγξτε ποιες ειδοποιήσεις λαμβάνετε",
"copyFullHex": "Αντιγραφή πλήρους Hex",
"copyRawResponse": "Αντιγραφή ακατέργαστης απόκρισης",
"couldNotLoadMetHistory": "Αδυναμία φόρτωσης ιστορικού MET",
"customizeSensor": "Προσαρμογή {name}",
"data": "Δεδομένα",
"deleteGroup": "Διαγραφή ομάδας",
"deleteStyleConfirm": "Διαγραφή {name};",
"deleteThisDrawing": "Διαγραφή αυτού του {type};",
"deny": "Απόρριψη",
"deviceInfo": "Πληροφορίες συσκευής",
"deviceSettings": "Ρυθμίσεις συσκευής",
"deviceTimeSynced": "Ο χρόνος της συσκευής συγχρονίστηκε με αυτό το τηλέφωνο.",
"disableFastGpsPublishing": "Απενεργοποίηση γρήγορης δημοσίευσης GPS",
"displayMarkersAndTraceDatabase": "Εμφάνιση, δείκτες και βάση δεδομένων ιχνών",
"download": "Λήψη",
"environmentalTelemetry": "Περιβαλλοντική τηλεμετρία",
"errorCheckingForUpdates": "Σφάλμα ελέγχου ενημερώσεων: {error}",
"errorGeneric": "Σφάλμα: {error}",
"errorOpeningDownload": "Σφάλμα ανοίγματος λήψης: {error}",
"exportLogs": "Εξαγωγή αρχείων καταγραφής",
"extraAggressiveAvifCompression": "Εξαιρετικά επιθετική συμπίεση AVIF",
"failedToAddContact": "Αποτυχία προσθήκης επαφής: {error}",
"failedToAddName": "Αποτυχία προσθήκης {name}: {error}",
"failedToClearChannels": "Αποτυχία εκκαθάρισης καναλιών: {error}",
"failedToClearContacts": "Αποτυχία εκκαθάρισης επαφών: {error}",
"failedToConnectViaSerial": "Αποτυχία σύνδεσης μέσω σειριακής θύρας",
"failedToLoadPreviewImage": "Αποτυχία φόρτωσης εικόνας προεπισκόπησης: {error}",
"failedToLoadTrace": "Αποτυχία φόρτωσης ίχνους: {error}",
"failedToSendSarMarker": "Αποτυχία αποστολής δείκτη SAR: {error}",
"failedToSetBuzzerMode": "Αποτυχία ρύθμισης λειτουργίας βομβητή: {error}",
"failedToSetGpsMode": "Αποτυχία ρύθμισης λειτουργίας GPS: {error}",
"failedToSyncDeviceTime": "Αποτυχία συγχρονισμού χρόνου συσκευής: {error}",
"failedToWipeDeviceData": "Αποτυχία διαγραφής δεδομένων συσκευής: {error}",
"fastGpsTargetChannel": "Κανάλι-στόχος γρήγορου GPS",
"fastUpdatesThresholdsAndPermissions": "Γρήγορες ενημερώσεις, όρια και δικαιώματα",
"fetchingCatalog": "Ανάκτηση καταλόγου...",
"fifteenMinutes": "15 λεπτά",
"fifteenSeconds": "15 δευτερόλεπτα",
"filterByDirection": "Φιλτράρισμα κατά κατεύθυνση",
"finish": "Τέλος",
"fiveMinutes": "5 λεπτά",
"fiveSeconds": "5 δευτερόλεπτα",
"fix": "Στίξη",
"gps": "GPS",
"gpsAdvertPolicy": "Πολιτική διαφήμισης GPS",
"gpsAndLocation": "GPS & Τοποθεσία",
"gpsDiagnostics": "Διαγνωστικά GPS",
"gpsSection": "GPS",
"help": "Βοήθεια",
"hex": "Hex",
"hidden": "Κρυφό",
"howTheRadioAutoAddsDiscoveredNodes": "Πώς το ραδιόφωνο προσθέτει αυτόματα ανακαλυφθέντες κόμβους.",
"images": "Εικόνες",
"importProfile": "Εισαγωγή προφίλ",
"lastFix": "Τελευταία στίξη",
"latest": "Πιο πρόσφατο",
"latitude": "Γεωγραφικό πλάτος",
"loadASavedRegion": "Φόρτωση αποθηκευμένης περιοχής",
"loading": "Φόρτωση",
"locationSharingHardwareAndUpdateInterval": "Κοινοποίηση τοποθεσίας, υλικό και διάστημα ενημέρωσης.",
"locationTelemetry": "Τηλεμετρία τοποθεσίας",
"lockDestination": "Κλείδωμα προορισμού",
"longitude": "Γεωγραφικό μήκος",
"mapStyle": "Στυλ χάρτη",
"max": "Μέγ.",
"maxZoom": "Μέγ. ζουμ",
"messaging": "Μηνύματα",
"metHistory": "Ιστορικό MET",
"min": "Ελάχ.",
"minZoom": "Ελάχ. ζουμ",
"moveDown": "Μετακίνηση κάτω",
"moveUp": "Μετακίνηση πάνω",
"multiAckMode": "Λειτουργία πολλαπλού ACK",
"multiDeviceWorkspaceManagement": "Διαχείριση χώρου εργασίας πολλαπλών συσκευών",
"myLocation": "Η τοποθεσία μου",
"nameAndTelemetryShared": "Το όνομα και η τηλεμετρία κοινοποιούνται σε άλλες συσκευές.",
"newVersionAvailable": "Νέα έκδοση: {version}",
"newer": "Νεότερο",
"noNeighboursFound": "Δεν βρέθηκαν γείτονες",
"noServersFound": "Δεν βρέθηκαν διακομιστές",
"notSet": "Δεν έχει οριστεί",
"notifications": "Ειδοποιήσεις",
"offZeroSeconds": "Ανενεργό (0 δ)",
"offlineMaps": "Χάρτες εκτός σύνδεσης",
"older": "Παλαιότερο",
"oneByteMode0": "1 byte (mode 0)",
"oneHour": "1 ώρα",
"oneMinute": "1 λεπτό",
"openPacketLogs": "Άνοιγμα αρχείων πακέτων",
"packetTypeHelp": "Βοήθεια τύπων πακέτων",
"pasteShareLinkOrHexAdvert": "Επικολλήστε σύνδεσμο κοινοποίησης ή hex διαφήμιση",
"pathHash": "Hash διαδρομής",
"pathSize": "Μέγεθος διαδρομής",
"ping": "Ping",
"polygon": "Πολύγωνο",
"rawResponseCopied": "Η ακατέργαστη απόκριση αντιγράφηκε",
"resendThroughNearestRepeaterOnFailure": "Επαναποστολή μέσω πλησιέστερου αναμεταδότη σε αποτυχία",
"resolveContact": "Επίλυση επαφής",
"routingRetriesAndDestinationLock": "Δρομολόγηση, επαναπροσπάθειες και κλείδωμα προορισμού",
"samples": "Δείγματα",
"satellites": "Δορυφόροι",
"saving": "Αποθήκευση...",
"search": "Αναζήτηση",
"searchByNameOrToken": "Αναζήτηση με όνομα ή token",
"searchDiscoveredNodes": "Αναζήτηση ανακαλυφθέντων κόμβων",
"searchInCurrentFilter": "Αναζήτηση στο τρέχον φίλτρο",
"searchLogs": "Αναζήτηση αρχείων καταγραφής...",
"searchSensors": "Αναζήτηση αισθητήρων",
"searchThisSection": "Αναζήτηση σε αυτήν την ενότητα",
"selectedRelays": "Επιλεγμένοι αναμεταδότες",
"sensor": "Αισθητήρας",
"serialError": "Σφάλμα σειριακής: {error}",
"setPathForContact": "Ορισμός διαδρομής για {name}",
"shareLiveGps": "Κοινοποίηση ζωντανού GPS",
"shareMyTiles": "Κοινοποίηση πλακιδίων μου",
"sharingLocationFromPhone": "Κοινοποίηση τοποθεσίας σε αυτό το κανάλι από το τηλέφωνο.",
"sharingLocationFromRadio": "Κοινοποίηση τοποθεσίας σε αυτό το κανάλι από το ραδιόφωνο.",
"signalDbm": "Σήμα {rssi} dBm",
"sizeCompressionAndPreview": "Μέγεθος, συμπίεση και προεπισκόπηση",
"skip": "Παράλειψη",
"smallerFileSize": "Μικρότερο μέγεθος αρχείου",
"sort": "Ταξινόμηση",
"stoppedSharingLocation": "Σταμάτησε η κοινοποίηση τοποθεσίας σε αυτό το κανάλι.",
"suppressNotificationsWhileInForeground": "Αποσιώπηση ειδοποιήσεων όταν η εφαρμογή είναι στο προσκήνιο",
"tabsAndNavigation": "Καρτέλες & Πλοήγηση",
"templatesAndTutorials": "Πρότυπα και οδηγίες",
"tenMinutes": "10 λεπτά",
"tenSeconds": "10 δευτερόλεπτα",
"testSendUpdate": "Δοκιμαστική αποστολή ενημέρωσης",
"themeLanguageAndDisplayPreferences": "Θέμα, γλώσσα και προτιμήσεις εμφάνισης",
"thirtyMinutes": "30 λεπτά",
"thirtySeconds": "30 δευτερόλεπτα",
"threeBytesMode2": "3 bytes (mode 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Στατιστικά κίνησης, ιστορικό μηνυμάτων και εργαλεία προγραμματιστή",
"twoBytesMode1": "2 bytes (mode 1)",
"twoMinutes": "2 λεπτά",
"undo": "Αναίρεση",
"updated": "Ενημερώθηκε",
"useContactFlags": "Χρήση σημαιών επαφής",
"useSavedCoordinates": "Χρήση αποθηκευμένων συντεταγμένων",
"view": "Προβολή",
"viewNeighbours": "Προβολή γειτόνων",
"viewPublicStats": "Προβολή δημόσιων στατιστικών",
"setRegionScope": "Ορισμός εύρους περιοχής",
"regionScope": "Εύρος περιοχής",
"regionScopeNone": "Κανένα (καθολικό)",
"clearRegionScope": "Εκκαθάριση εύρους",
"regionScopeWarning": "Μόνο οι αναμεταδότες που επιτρέπουν αυτή την περιοχή θα προωθήσουν.",
"discoverRegions": "Ανακάλυψη από αναμεταδότες",
"discoveringRegions": "Ανακάλυψη περιοχών...",
"enterRegionName": "Όνομα περιοχής (π.χ. auckland)",
"noRegionsFound": "Δεν βρέθηκαν περιοχές σε αυτόν τον αναμεταδότη.",
"regionScopeSet": "Εύρος περιοχής ορίστηκε σε {name}",
"regionScopeCleared": "Εύρος περιοχής εκκαθαρίστηκε"
}

View File

@@ -36,10 +36,6 @@
"@scanAgain": {
"description": "Button to restart BLE scanning"
},
"tapToConnect": "Tap to connect",
"@tapToConnect": {
"description": "Subtitle text for device in scan list"
},
"deviceNotConnected": "Device not connected",
"@deviceNotConnected": {
"description": "Error message when device is not connected"
@@ -133,18 +129,10 @@
"@showRxTxIndicators": {
"description": "Setting to show RX/TX indicators"
},
"displayPacketActivity": "Display packet activity indicators in top bar",
"@displayPacketActivity": {
"description": "Description for RX/TX indicators setting"
},
"disableMap": "Disable Map",
"@disableMap": {
"description": "Setting to disable the map tab"
},
"disableMapDescription": "Hide the map tab to reduce battery usage",
"@disableMapDescription": {
"description": "Description for disable map setting"
},
"language": "Language",
"@language": {
"description": "Language setting label"
@@ -173,10 +161,6 @@
"@appVersion": {
"description": "App version label"
},
"appName": "App Name",
"@appName": {
"description": "App name label"
},
"aboutMeshCoreSar": "About MeshCore SAR",
"@aboutMeshCoreSar": {
"description": "About dialog title"
@@ -308,10 +292,6 @@
"@resetPath": {
"description": "Action to reset contact path for re-routing"
},
"publicKeyCopied": "Public key copied to clipboard",
"@publicKeyCopied": {
"description": "Success message when public key is copied"
},
"copiedToClipboard": "{label} copied to clipboard",
"@copiedToClipboard": {
"description": "Success message when a value is copied to clipboard",
@@ -660,6 +640,30 @@
"@flood": {
"description": "Flood routing indicator"
},
"autoSend": "Auto Send",
"@autoSend": {
"description": "Auto send mode - uses current path"
},
"autoSendDescription": "Send via current path.",
"@autoSendDescription": {
"description": "Description for auto send mode"
},
"sendDirect": "Send Direct",
"@sendDirect": {
"description": "Send direct mode - zero-hop to contact"
},
"sendDirectDescription": "Send directly to this contact.",
"@sendDirectDescription": {
"description": "Description for send direct mode"
},
"sendFlood": "Send Flood",
"@sendFlood": {
"description": "Send flood mode - via all repeaters"
},
"sendFloodDescription": "Send via all repeaters.",
"@sendFloodDescription": {
"description": "Description for send flood mode"
},
"loggedIn": "Logged In",
"@loggedIn": {
"description": "Logged in status badge"
@@ -720,22 +724,6 @@
"@lastSeen": {
"description": "Last seen label"
},
"roomStatus": "Room Status",
"@roomStatus": {
"description": "Room status section header"
},
"loginStatus": "Login Status",
"@loginStatus": {
"description": "Login status label"
},
"notLoggedIn": "Not Logged In",
"@notLoggedIn": {
"description": "Not logged in status"
},
"adminAccess": "Admin Access",
"@adminAccess": {
"description": "Admin access label"
},
"yes": "Yes",
"@yes": {
"description": "Yes answer"
@@ -748,14 +736,6 @@
"@permissions": {
"description": "Permissions label"
},
"passwordSaved": "Password Saved",
"@passwordSaved": {
"description": "Password saved label"
},
"locationColon": "Location:",
"@locationColon": {
"description": "Location section header"
},
"telemetry": "Telemetry",
"@telemetry": {
"description": "Telemetry section header"
@@ -784,19 +764,6 @@
"@gpsTelemetry": {
"description": "GPS from telemetry label"
},
"updated": "Updated",
"@updated": {
"description": "Updated timestamp label"
},
"pathResetInfo": "Path reset for {name}. Next message will find a new route.",
"@pathResetInfo": {
"description": "Info message after path reset",
"placeholders": {
"name": {
"type": "String"
}
}
},
"reLoginToRoom": "Re-Login to Room",
"@reLoginToRoom": {
"description": "Button to re-login to room"
@@ -1158,10 +1125,6 @@
"@frequencyMHz": {
"description": "Label for radio frequency field"
},
"frequencyExample": "e.g., 869.618",
"@frequencyExample": {
"description": "Helper text example for frequency"
},
"bandwidth": "Bandwidth",
"@bandwidth": {
"description": "Label for bandwidth dropdown"
@@ -1265,47 +1228,6 @@
"@trailControls": {
"description": "Trail controls tooltip"
},
"contactTrails": "Contact Trails",
"@contactTrails": {
"description": "Contact trails section header"
},
"showAllContactTrails": "Show All Contact Trails",
"@showAllContactTrails": {
"description": "Toggle label to show all contact trails"
},
"noContactsWithLocationHistory": "No contacts with location history",
"@noContactsWithLocationHistory": {
"description": "Subtitle when no contacts have trails"
},
"showingTrailsForContacts": "Showing trails for {count} contacts",
"@showingTrailsForContacts": {
"description": "Subtitle showing number of contacts with trails",
"placeholders": {
"count": {
"type": "int"
}
}
},
"individualContactTrails": "Individual Contact Trails",
"@individualContactTrails": {
"description": "Expansion tile title for individual contact trails"
},
"deviceInformation": "Device Information",
"@deviceInformation": {
"description": "Device information section header"
},
"bleName": "BLE Name",
"@bleName": {
"description": "Bluetooth Low Energy device name label"
},
"meshName": "Mesh Name",
"@meshName": {
"description": "Mesh network name label"
},
"notSet": "Not set",
"@notSet": {
"description": "Label when a value is not set"
},
"model": "Model",
"@model": {
"description": "Device model label"
@@ -1314,14 +1236,6 @@
"@version": {
"description": "Version label"
},
"buildDate": "Build Date",
"@buildDate": {
"description": "Firmware build date label"
},
"firmware": "Firmware",
"@firmware": {
"description": "Firmware label"
},
"maxContacts": "Max Contacts",
"@maxContacts": {
"description": "Maximum contacts capacity label"
@@ -1334,26 +1248,6 @@
"@publicInfo": {
"description": "Public information section header"
},
"meshNetworkName": "Mesh Network Name",
"@meshNetworkName": {
"description": "Mesh network name field label"
},
"nameBroadcastInMesh": "Name broadcast in mesh advertisements",
"@nameBroadcastInMesh": {
"description": "Helper text for mesh network name field"
},
"telemetryAndLocationSharing": "Telemetry & Location Sharing",
"@telemetryAndLocationSharing": {
"description": "Telemetry and location sharing toggle label"
},
"lat": "Lat",
"@lat": {
"description": "Latitude field label (short form)"
},
"lon": "Lon",
"@lon": {
"description": "Longitude field label (short form)"
},
"useCurrentLocation": "Use current location",
"@useCurrentLocation": {
"description": "Tooltip for use current location button"
@@ -1383,15 +1277,6 @@
}
}
},
"copiedToClipboardShort": "Copied {label} to clipboard",
"@copiedToClipboardShort": {
"description": "Short success message when copying to clipboard",
"placeholders": {
"label": {
"type": "String"
}
}
},
"failedToSave": "Failed to save: {error}",
"@failedToSave": {
"description": "Generic error message for save failures",
@@ -1414,10 +1299,6 @@
"@sarTemplates": {
"description": "SAR templates menu title"
},
"manageSarTemplates": "Manage cursor on target templates",
"@manageSarTemplates": {
"description": "Subtitle for SAR templates settings"
},
"addTemplate": "Add Template",
"@addTemplate": {
"description": "Button to add new SAR template"
@@ -1541,10 +1422,6 @@
"@ok": {
"description": "OK button label"
},
"permissionsSection": "Permissions",
"@permissionsSection": {
"description": "Permissions section header"
},
"locationPermission": "Location Permission",
"@locationPermission": {
"description": "Location permission label"
@@ -2109,94 +1986,6 @@
"@wizardWelcomeTitle": {
"description": "Welcome wizard first page title"
},
"wizardWelcomeDescription": "A powerful off-grid communication tool for search and rescue operations. Connect with your team using mesh radio technology when traditional networks are unavailable.",
"@wizardWelcomeDescription": {
"description": "Welcome wizard first page description"
},
"wizardConnectingTitle": "Connecting to Your Radio",
"@wizardConnectingTitle": {
"description": "Wizard connecting page title"
},
"wizardConnectingDescription": "Connect your smartphone to a MeshCore radio device via Bluetooth to start communicating off-grid.",
"@wizardConnectingDescription": {
"description": "Wizard connecting page description"
},
"wizardConnectingFeature1": "Scan for nearby MeshCore devices",
"@wizardConnectingFeature1": {
"description": "Wizard connecting feature 1"
},
"wizardConnectingFeature2": "Pair with your radio via Bluetooth",
"@wizardConnectingFeature2": {
"description": "Wizard connecting feature 2"
},
"wizardConnectingFeature3": "Works completely offline - no internet required",
"@wizardConnectingFeature3": {
"description": "Wizard connecting feature 3"
},
"wizardChannelTitle": "Channels",
"@wizardChannelTitle": {
"description": "Wizard channel page title"
},
"wizardChannelDescription": "Broadcast messages to everyone on a channel, perfect for team-wide announcements and coordination.",
"@wizardChannelDescription": {
"description": "Wizard channel page description"
},
"wizardChannelFeature1": "Public Channel for general team communication",
"@wizardChannelFeature1": {
"description": "Wizard channel feature 1"
},
"wizardChannelFeature2": "Create custom channels for specific groups",
"@wizardChannelFeature2": {
"description": "Wizard channel feature 2"
},
"wizardChannelFeature3": "Messages are automatically relayed by the mesh",
"@wizardChannelFeature3": {
"description": "Wizard channel feature 3"
},
"wizardContactsTitle": "Contacts",
"@wizardContactsTitle": {
"description": "Wizard contacts page title"
},
"wizardContactsDescription": "Your team members appear automatically as they join the mesh network. Send them direct messages or view their location.",
"@wizardContactsDescription": {
"description": "Wizard contacts page description"
},
"wizardContactsFeature1": "Contacts discovered automatically",
"@wizardContactsFeature1": {
"description": "Wizard contacts feature 1"
},
"wizardContactsFeature2": "Send private direct messages",
"@wizardContactsFeature2": {
"description": "Wizard contacts feature 2"
},
"wizardContactsFeature3": "View battery level and last seen time",
"@wizardContactsFeature3": {
"description": "Wizard contacts feature 3"
},
"wizardMapTitle": "Map & Location",
"@wizardMapTitle": {
"description": "Wizard map page title"
},
"wizardMapDescription": "Track your team in real-time and mark important locations for search and rescue operations.",
"@wizardMapDescription": {
"description": "Wizard map page description"
},
"wizardMapFeature1": "SAR markers for found persons, fires, and staging areas",
"@wizardMapFeature1": {
"description": "Wizard map feature 1"
},
"wizardMapFeature2": "Real-time GPS tracking of team members",
"@wizardMapFeature2": {
"description": "Wizard map feature 2"
},
"wizardMapFeature3": "Download offline maps for remote areas",
"@wizardMapFeature3": {
"description": "Wizard map feature 3"
},
"wizardMapFeature4": "Draw shapes and share tactical information",
"@wizardMapFeature4": {
"description": "Wizard map feature 4"
},
"viewWelcomeTutorial": "View Welcome Tutorial",
"@viewWelcomeTutorial": {
"description": "Settings option to re-show welcome wizard"
@@ -2229,9 +2018,9 @@
},
"reply": "Reply",
"@reply": {},
"technicalDetails": "Technical details",
"technicalDetails": "Details",
"@technicalDetails": {},
"messageTechnicalDetails": "Message technical details",
"messageTechnicalDetails": "Message details",
"@messageTechnicalDetails": {},
"linkQuality": "Link quality",
"@linkQuality": {},
@@ -2285,7 +2074,7 @@
"@change": {
"description": "Generic change action"
},
"wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, mapping, and device tools in one place.",
"wizardOverviewDescription": "This app combines MeshCore messaging, SAR field updates, discovery, mapping, and device tools in one place.",
"@wizardOverviewDescription": {
"description": "Updated onboarding overview description"
},
@@ -2293,11 +2082,11 @@
"@wizardOverviewFeature1": {
"description": "Updated onboarding overview feature 1"
},
"wizardOverviewFeature2": "Share SAR markers, map drawings, voice clips, and images over the mesh.",
"wizardOverviewFeature2": "Share live location to a private channel, plus SAR markers, map drawings, voice clips, and images over the mesh.",
"@wizardOverviewFeature2": {
"description": "Updated onboarding overview feature 2"
},
"wizardOverviewFeature3": "Connect over BLE or TCP, then manage the companion radio from inside the app.",
"wizardOverviewFeature3": "Connect over BLE, WiFi, or Serial, then manage the companion radio from inside the app.",
"@wizardOverviewFeature3": {
"description": "Updated onboarding overview feature 3"
},
@@ -2410,11 +2199,11 @@
"@wizardNetworkTitle": {
"description": "Updated onboarding network page title"
},
"wizardNetworkDescription": "The Contacts tab organizes the network you discover and the routes you learn over time.",
"wizardNetworkDescription": "The Contacts and Discovery flows help you find nearby nodes, store repeaters, and manage the routes you learn over time.",
"@wizardNetworkDescription": {
"description": "Updated onboarding network page description"
},
"wizardNetworkFeature1": "Review team members, repeaters, rooms, channels, and pending adverts in one list.",
"wizardNetworkFeature1": "Discover repeaters and sensors, then review team members, rooms, channels, and pending adverts in one list.",
"@wizardNetworkFeature1": {
"description": "Updated onboarding network feature 1"
},
@@ -2422,7 +2211,7 @@
"@wizardNetworkFeature2": {
"description": "Updated onboarding network feature 2"
},
"wizardNetworkFeature3": "Create channels and manage network destinations without leaving the app.",
"wizardNetworkFeature3": "Add new repeaters right after you connect, then create channels and manage network destinations without leaving the app.",
"@wizardNetworkFeature3": {
"description": "Updated onboarding network feature 3"
},
@@ -2434,7 +2223,7 @@
"@wizardMapOpsDescription": {
"description": "Updated onboarding map page description"
},
"wizardMapOpsFeature1": "Track your own position, teammate locations, and movement trails on the map.",
"wizardMapOpsFeature1": "Track your own position, teammate locations, and share live location to a private channel from the channel menu.",
"@wizardMapOpsFeature1": {
"description": "Updated onboarding map feature 1"
},
@@ -2462,10 +2251,18 @@
"@wizardToolsFeature2": {
"description": "Updated onboarding tools feature 2"
},
"wizardToolsFeature3": "Use packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.",
"wizardToolsFeature3": "Use live traffic, packet logs, spectrum scan, and developer diagnostics when troubleshooting the mesh.",
"@wizardToolsFeature3": {
"description": "Updated onboarding tools feature 3"
},
"postConnectDiscoveryTitle": "Discover repeaters now?",
"@postConnectDiscoveryTitle": {
"description": "Title for the prompt shown after a successful device connection"
},
"postConnectDiscoveryDescription": "Run repeater discovery right after connecting so you can see nearby MeshCore nodes and add them to your network faster.",
"@postConnectDiscoveryDescription": {
"description": "Body text for the prompt shown after a successful device connection"
},
"contactInSensors": "In Sensors",
"@contactInSensors": {
"description": "Contact actions label when already in sensors"
@@ -2547,29 +2344,10 @@
"posttxDelay": "Post-tx delay",
"bandpass": "Band-pass",
"bandpassFilterVoice": "Band-pass filter voice",
"frequency": "Frequency",
"australia": "Australia",
"australiaNarrow": "Australia (Narrow)",
"australiaQld": "Australia: QLD",
"australiaSaWa": "Australia: SA, WA",
"newZealand": "New Zealand",
"newZealandNarrow": "New Zealand (Narrow)",
"switzerland": "Switzerland",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "Czech Republic (Narrow)",
"eu433mhzLongRange": "EU 433MHz (Long Range)",
"euukDeprecated": "EU/UK (Deprecated)",
"euukNarrow": "EU/UK (Narrow)",
"usacanadaRecommended": "USA/Canada (Recommended)",
"vietnamDeprecated": "Vietnam (Deprecated)",
"vietnamNarrow": "Vietnam (Narrow)",
"active": "Active",
"addContact": "Add Contact",
"all": "All",
"autoResolve": "Auto resolve",
"clearAllLabel": "Clear all",
"clearRelays": "Clear relays",
"clearFilters": "Clear filters",
"clearRoute": "Clear Route",
"clearMessages": "Clear Messages",
@@ -2595,10 +2373,7 @@
"send": "Send",
"sendAnyway": "Send anyway",
"share": "Share",
"shareContact": "Share Contact",
"trace": "Trace",
"use": "Use",
"useSelectedFrequency": "Use selected frequency",
"discovery": "Discovery",
"discoverRepeaters": "Discover repeaters",
"discoverSensors": "Discover sensors",
@@ -2621,7 +2396,6 @@
"gpsModule": "GPS Module",
"liveTraffic": "Live Traffic",
"repeatersMap": "Repeaters Map",
"spectrumScan": "Spectrum Scan",
"blePacketLogs": "BLE Packet Logs",
"onlineTraceDatabase": "Online trace database",
"routePathByteSize": "Route path byte size",
@@ -2633,7 +2407,6 @@
"disableContacts": "Disable Contacts",
"enableSensorsTab": "Enable Sensors tab",
"enableProfiles": "Enable Profiles",
"autoRouteRotation": "Auto route rotation",
"nearestRepeaterFallback": "Nearest repeater fallback",
"deleteAllStoredMessageHistory": "Delete all stored message history",
"messageFontSize": "Message font size",
@@ -2641,18 +2414,13 @@
"showMapDebugInfo": "Show map debug info",
"openMapInFullscreen": "Open map in fullscreen",
"showSarMarkersLabel": "Show SAR markers",
"displaySarMarkersOnTheMainMap": "Display SAR markers on the main map",
"showAllContactTrailsLabel": "Show all contact trails",
"hideRepeatersOnMap": "Hide repeaters on map",
"setMapScale": "Set map scale",
"customMapScaleSaved": "Custom map scale saved",
"voiceBitrate": "Voice bitrate",
"voiceCompressor": "Voice compressor",
"balancesQuietAndLoudSpeechLevels": "Balances quiet and loud speech levels",
"voiceLimiter": "Voice limiter",
"preventsClippingPeaksBeforeEncoding": "Prevents clipping peaks before encoding",
"micAutoGain": "Mic auto gain",
"letsTheRecorderAdjustInputLevel": "Lets the recorder adjust input level",
"echoCancellation": "Echo cancellation",
"noiseSuppression": "Noise suppression",
"trimSilenceInVoiceMessages": "Trim silence in voice messages",
@@ -2694,13 +2462,10 @@
"staging": "Staging",
"object": "Object",
"quiet": "Quiet",
"moderate": "Moderate",
"busy": "Busy",
"spectrumScanReturnedNoCandidateFrequencies": "Spectrum scan returned no candidate frequencies",
"searchMessages": "Search messages",
"sendImageFromGallery": "Send image from gallery",
"takePhoto": "Take photo",
"dmOnly": "DM only",
"allMessages": "All messages",
"sendToPublicChannel": "Send to Public Channel?",
"selectMarkerTypeAndDestination": "Select marker type and destination",
@@ -2719,8 +2484,6 @@
"wipeDevice": "Wipe device",
"destructiveDeviceActions": "Destructive device actions.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Choose a preset or fine-tune custom radio settings.",
"chooseTheNameAndLocationThisDeviceShares": "Choose the name and location this device shares.",
"availableSpaceOnThisDevice": "Available space on this device.",
"used": "Used",
"total": "Total",
"renameValue": "Rename value",
@@ -2738,8 +2501,6 @@
"onlineOnly": "Online only",
"inBoth": "In both",
"source": "Source",
"manualRouteEdit": "Manual route edit",
"observedMeshRoute": "Observed mesh route",
"allMessagesCleared": "All messages cleared",
"onlineTraceDatabaseCleared": "Online trace database cleared",
"packetLogsCleared": "Packet logs cleared",
@@ -2770,5 +2531,357 @@
"captured": "Captured",
"size": "Size",
"noCustomChannelsToClear": "No custom channels to clear.",
"noDeviceContactsToClear": "No device contacts to clear."
"noDeviceContactsToClear": "No device contacts to clear.",
"aToZ": "A-Z",
"add": "Add",
"addAdditionalDetails": "Add additional details",
"addPeer": "Add Peer",
"addPeerManually": "Add peer manually",
"addedToContacts": "{name} added to contacts",
"@addedToContacts": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"addedToSensors": "{name} added to Sensors",
"@addedToSensors": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"allowAll": "Allow all",
"anonymousRxStats": "Anonymous RX stats",
"appearance": "Appearance",
"ascii": "ASCII",
"baseTelemetry": "Base telemetry",
"blePin": "BLE PIN",
"buzzerAlerts": "Buzzer alerts",
"cancelImageReceive": "Cancel image receive",
"choose": "Choose",
"chooseWhichTabsAndContactSectionsToShow": "Choose which tabs and contact sections to show",
"clearCache": "Clear cache",
"clearHistory": "Clear history",
"clearLiveView": "Clear live view",
"clearLogs": "Clear logs",
"clearOfflineCache": "Clear offline cache?",
"clearOverlay": "Clear overlay",
"clearRouteOnlyAfterAllRetriesFail": "Clear route only after all retries fail",
"codecBitrateAndAudioProcessing": "Codec, bitrate, and audio processing",
"connectByIpAddress": "Connect by IP address",
"controlWhichAlertsYouReceive": "Control which alerts you receive",
"copyFullHex": "Copy full hex",
"copyRawResponse": "Copy raw response",
"couldNotLoadMetHistory": "Could not load MET history",
"customizeSensor": "Customize {name}",
"@customizeSensor": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"data": "Data",
"deleteGroup": "Delete group",
"deleteStyleConfirm": "Delete {name}?",
"@deleteStyleConfirm": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"deleteThisDrawing": "Delete this {type}?",
"@deleteThisDrawing": {
"placeholders": {
"type": {
"type": "String"
}
}
},
"deny": "Deny",
"deviceInfo": "Device info",
"deviceSettings": "Device Settings",
"deviceTimeSynced": "Device time synced to this phone.",
"disableFastGpsPublishing": "Disable fast GPS publishing",
"displayMarkersAndTraceDatabase": "Display, markers, and trace database",
"download": "Download",
"environmentalTelemetry": "Environmental telemetry",
"errorCheckingForUpdates": "Error checking for updates: {error}",
"@errorCheckingForUpdates": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"errorGeneric": "Error: {error}",
"@errorGeneric": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"errorOpeningDownload": "Error opening download: {error}",
"@errorOpeningDownload": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"exportLogs": "Export logs",
"extraAggressiveAvifCompression": "Extra-aggressive AVIF compression",
"failedToAddContact": "Failed to add contact: {error}",
"@failedToAddContact": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToAddName": "Failed to add {name}: {error}",
"@failedToAddName": {
"placeholders": {
"name": {
"type": "String"
},
"error": {
"type": "String"
}
}
},
"failedToClearChannels": "Failed to clear channels: {error}",
"@failedToClearChannels": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToClearContacts": "Failed to clear contacts: {error}",
"@failedToClearContacts": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToConnectViaSerial": "Failed to connect via serial",
"failedToLoadPreviewImage": "Failed to load preview image: {error}",
"@failedToLoadPreviewImage": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToLoadTrace": "Failed to load trace: {error}",
"@failedToLoadTrace": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToSendSarMarker": "Failed to send SAR marker: {error}",
"@failedToSendSarMarker": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToSetBuzzerMode": "Failed to set buzzer mode: {error}",
"@failedToSetBuzzerMode": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToSetGpsMode": "Failed to set GPS mode: {error}",
"@failedToSetGpsMode": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToSyncDeviceTime": "Failed to sync device time: {error}",
"@failedToSyncDeviceTime": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"failedToWipeDeviceData": "Failed to wipe device data: {error}",
"@failedToWipeDeviceData": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"fastGpsTargetChannel": "Fast GPS target channel",
"fastUpdatesThresholdsAndPermissions": "Fast updates, thresholds, and permissions",
"fetchingCatalog": "Fetching catalog...",
"fifteenMinutes": "15 minutes",
"fifteenSeconds": "15 seconds",
"filterByDirection": "Filter by direction",
"finish": "Finish",
"fiveMinutes": "5 minutes",
"fiveSeconds": "5 seconds",
"fix": "Fix",
"gps": "GPS",
"gpsAdvertPolicy": "GPS advert policy",
"gpsAndLocation": "GPS & Location",
"gpsDiagnostics": "GPS diagnostics",
"gpsSection": "GPS",
"help": "Help",
"hex": "Hex",
"hidden": "Hidden",
"howTheRadioAutoAddsDiscoveredNodes": "How the radio auto-adds discovered nodes.",
"images": "Images",
"importProfile": "Import profile",
"lastFix": "Last fix",
"latest": "Latest",
"latitude": "Latitude",
"loadASavedRegion": "Load a saved region",
"loading": "Loading",
"locationSharingHardwareAndUpdateInterval": "Location sharing, hardware, and update interval.",
"locationTelemetry": "Location telemetry",
"lockDestination": "Lock destination",
"longitude": "Longitude",
"mapStyle": "Map Style",
"max": "Max",
"maxZoom": "Max Zoom",
"messaging": "Messaging",
"metHistory": "MET history",
"min": "Min",
"minZoom": "Min Zoom",
"moveDown": "Move down",
"moveUp": "Move up",
"multiAckMode": "Multi-ACK mode",
"multiDeviceWorkspaceManagement": "Multi-device workspace management",
"myLocation": "My location",
"nameAndTelemetryShared": "Name and telemetry shared with other devices.",
"newVersionAvailable": "New version: {version}",
"@newVersionAvailable": {
"placeholders": {
"version": {
"type": "String"
}
}
},
"newer": "Newer",
"noNeighboursFound": "No neighbours found",
"noServersFound": "No servers found",
"notSet": "Not set",
"notifications": "Notifications",
"offZeroSeconds": "Off (0 s)",
"offlineMaps": "Offline Maps",
"older": "Older",
"oneByteMode0": "1 byte (mode 0)",
"oneHour": "1 hour",
"oneMinute": "1 minute",
"openPacketLogs": "Open packet logs",
"packetTypeHelp": "Packet type help",
"pasteShareLinkOrHexAdvert": "Paste a share link or hex advert",
"pathHash": "Path hash",
"pathSize": "Path size",
"ping": "Ping",
"polygon": "Polygon",
"rawResponseCopied": "Raw response copied",
"resendThroughNearestRepeaterOnFailure": "Resend through nearest repeater on failure",
"resolveContact": "Resolve contact",
"routingRetriesAndDestinationLock": "Routing, retries, and destination lock",
"samples": "Samples",
"satellites": "Satellites",
"saving": "Saving...",
"search": "Search",
"searchByNameOrToken": "Search by name or token",
"searchDiscoveredNodes": "Search discovered nodes",
"searchInCurrentFilter": "Search in current filter",
"searchLogs": "Search logs...",
"searchSensors": "Search sensors",
"searchThisSection": "Search this section",
"selectedRelays": "Selected relays",
"sensor": "Sensor",
"serialError": "Serial error: {error}",
"@serialError": {
"placeholders": {
"error": {
"type": "String"
}
}
},
"setPathForContact": "Set Path for {name}",
"@setPathForContact": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"shareLiveGps": "Share live GPS",
"shareMyTiles": "Share my tiles",
"sharingLocationFromPhone": "Sharing location on this channel from the phone.",
"sharingLocationFromRadio": "Sharing location on this channel from the radio.",
"signalDbm": "Signal {rssi} dBm",
"@signalDbm": {
"placeholders": {
"rssi": {
"type": "String"
}
}
},
"sizeCompressionAndPreview": "Size, compression, and preview",
"skip": "Skip",
"smallerFileSize": "Smaller file size",
"sort": "Sort",
"stoppedSharingLocation": "Stopped sharing location on this channel.",
"suppressNotificationsWhileInForeground": "Suppress notifications while in foreground",
"tabsAndNavigation": "Tabs & Navigation",
"templatesAndTutorials": "Templates and tutorials",
"tenMinutes": "10 minutes",
"tenSeconds": "10 seconds",
"testSendUpdate": "Test send update",
"themeLanguageAndDisplayPreferences": "Theme, language, and display preferences",
"thirtyMinutes": "30 minutes",
"thirtySeconds": "30 seconds",
"threeBytesMode2": "3 bytes (mode 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Traffic stats, message history, and developer tools",
"twoBytesMode1": "2 bytes (mode 1)",
"twoMinutes": "2 minutes",
"undo": "Undo",
"updated": "Updated",
"useContactFlags": "Use contact flags",
"useSavedCoordinates": "Use saved coordinates",
"view": "View",
"viewNeighbours": "View Neighbours",
"viewPublicStats": "View public stats",
"setRegionScope": "Set region scope",
"regionScope": "Region scope",
"regionScopeNone": "None (global)",
"clearRegionScope": "Clear scope",
"regionScopeWarning": "Only repeaters allowing this region will forward.",
"discoverRegions": "Discover from repeaters",
"discoveringRegions": "Discovering regions...",
"enterRegionName": "Region name (e.g. auckland)",
"noRegionsFound": "No regions found on this repeater.",
"regionScopeSet": "Region scope set to {name}",
"regionScopeCleared": "Region scope cleared",
"@regionScopeSet": {
"placeholders": {
"name": {
"type": "String"
}
}
}
}

View File

@@ -36,10 +36,6 @@
"@scanAgain": {
"description": "Botón para reiniciar el escaneo BLE"
},
"tapToConnect": "Toca para conectar",
"@tapToConnect": {
"description": "Texto de subtítulo para dispositivo en la lista de escaneo"
},
"deviceNotConnected": "Dispositivo no conectado",
"@deviceNotConnected": {
"description": "Mensaje de error cuando el dispositivo no está conectado"
@@ -133,18 +129,10 @@
"@showRxTxIndicators": {
"description": "Configuración para mostrar indicadores RX/TX"
},
"displayPacketActivity": "Mostrar indicadores de actividad de paquetes en la barra superior",
"@displayPacketActivity": {
"description": "Descripción de la configuración de indicadores RX/TX"
},
"disableMap": "Desactivar mapa",
"@disableMap": {
"description": "Configuración para desactivar la pestaña del mapa"
},
"disableMapDescription": "Ocultar la pestaña del mapa para reducir el uso de batería",
"@disableMapDescription": {
"description": "Descripción de la configuración para desactivar el mapa"
},
"language": "Idioma",
"@language": {
"description": "Etiqueta de la configuración de idioma"
@@ -173,10 +161,6 @@
"@appVersion": {
"description": "Etiqueta de la versión de la aplicación"
},
"appName": "Nombre de la aplicación",
"@appName": {
"description": "Etiqueta del nombre de la aplicación"
},
"aboutMeshCoreSar": "Acerca de MeshCore SAR",
"@aboutMeshCoreSar": {
"description": "Título del diálogo acerca de"
@@ -308,10 +292,6 @@
"@resetPath": {
"description": "Acción para restablecer la ruta del contacto para re-enrutamiento"
},
"publicKeyCopied": "Clave pública copiada al portapapeles",
"@publicKeyCopied": {
"description": "Mensaje de éxito cuando se copia la clave pública"
},
"copiedToClipboard": "{label} copiado al portapapeles",
"@copiedToClipboard": {
"description": "Mensaje de éxito cuando se copia un valor al portapapeles",
@@ -720,22 +700,6 @@
"@lastSeen": {
"description": "Etiqueta de visto por última vez"
},
"roomStatus": "Estado de la sala",
"@roomStatus": {
"description": "Encabezado de la sección de estado de la sala"
},
"loginStatus": "Estado de inicio de sesión",
"@loginStatus": {
"description": "Etiqueta de estado de inicio de sesión"
},
"notLoggedIn": "No ha iniciado sesión",
"@notLoggedIn": {
"description": "Estado de no ha iniciado sesión"
},
"adminAccess": "Acceso de administrador",
"@adminAccess": {
"description": "Etiqueta de acceso de administrador"
},
"yes": "Sí",
"@yes": {
"description": "Respuesta sí"
@@ -748,14 +712,6 @@
"@permissions": {
"description": "Etiqueta de permisos"
},
"passwordSaved": "Contraseña guardada",
"@passwordSaved": {
"description": "Etiqueta de contraseña guardada"
},
"locationColon": "Ubicación:",
"@locationColon": {
"description": "Encabezado de la sección de ubicación"
},
"telemetry": "Telemetría",
"@telemetry": {
"description": "Encabezado de la sección de telemetría"
@@ -784,19 +740,6 @@
"@gpsTelemetry": {
"description": "Etiqueta de GPS desde telemetría"
},
"updated": "Actualizado",
"@updated": {
"description": "Etiqueta de marca de tiempo actualizada"
},
"pathResetInfo": "Ruta restablecida para {name}. El próximo mensaje encontrará una nueva ruta.",
"@pathResetInfo": {
"description": "Mensaje de información después de restablecer ruta",
"placeholders": {
"name": {
"type": "String"
}
}
},
"reLoginToRoom": "Re-iniciar sesión en la sala",
"@reLoginToRoom": {
"description": "Botón para re-iniciar sesión en la sala"
@@ -1114,10 +1057,6 @@
"@frequencyMHz": {
"description": "Etiqueta del campo de frecuencia de radio"
},
"frequencyExample": "ej., 869.618",
"@frequencyExample": {
"description": "Texto de ayuda de ejemplo para frecuencia"
},
"bandwidth": "Ancho de banda",
"@bandwidth": {
"description": "Etiqueta del menú desplegable de ancho de banda"
@@ -1221,47 +1160,6 @@
"@trailControls": {
"description": "Tooltip de controles del rastro"
},
"contactTrails": "Rastros de contactos",
"@contactTrails": {
"description": "Encabezado de la sección de rastros de contactos"
},
"showAllContactTrails": "Mostrar todos los rastros de contactos",
"@showAllContactTrails": {
"description": "Etiqueta del interruptor para mostrar todos los rastros de contactos"
},
"noContactsWithLocationHistory": "No hay contactos con historial de ubicación",
"@noContactsWithLocationHistory": {
"description": "Subtítulo cuando no hay contactos con rastros"
},
"showingTrailsForContacts": "Mostrando rastros para {count} contactos",
"@showingTrailsForContacts": {
"description": "Subtítulo que muestra el número de contactos con rastros",
"placeholders": {
"count": {
"type": "int"
}
}
},
"individualContactTrails": "Rastros individuales de contactos",
"@individualContactTrails": {
"description": "Título del elemento expandible para rastros individuales de contactos"
},
"deviceInformation": "Información del dispositivo",
"@deviceInformation": {
"description": "Encabezado de la sección de información del dispositivo"
},
"bleName": "Nombre BLE",
"@bleName": {
"description": "Etiqueta del nombre del dispositivo Bluetooth Low Energy"
},
"meshName": "Nombre Mesh",
"@meshName": {
"description": "Etiqueta del nombre de red mesh"
},
"notSet": "No establecido",
"@notSet": {
"description": "Etiqueta cuando no se establece un valor"
},
"model": "Modelo",
"@model": {
"description": "Etiqueta del modelo del dispositivo"
@@ -1270,14 +1168,6 @@
"@version": {
"description": "Etiqueta de versión"
},
"buildDate": "Fecha de compilación",
"@buildDate": {
"description": "Etiqueta de fecha de compilación del firmware"
},
"firmware": "Firmware",
"@firmware": {
"description": "Etiqueta de firmware"
},
"maxContacts": "Contactos máximos",
"@maxContacts": {
"description": "Etiqueta de capacidad máxima de contactos"
@@ -1290,26 +1180,6 @@
"@publicInfo": {
"description": "Encabezado de la sección de información pública"
},
"meshNetworkName": "Nombre de red Mesh",
"@meshNetworkName": {
"description": "Etiqueta del campo de nombre de red mesh"
},
"nameBroadcastInMesh": "Nombre difundido en anuncios mesh",
"@nameBroadcastInMesh": {
"description": "Texto de ayuda para el campo de nombre de red mesh"
},
"telemetryAndLocationSharing": "Telemetría y compartir ubicación",
"@telemetryAndLocationSharing": {
"description": "Etiqueta del alternar de telemetría y compartir ubicación"
},
"lat": "Lat",
"@lat": {
"description": "Etiqueta del campo de latitud (forma corta)"
},
"lon": "Lon",
"@lon": {
"description": "Etiqueta del campo de longitud (forma corta)"
},
"useCurrentLocation": "Usar ubicación actual",
"@useCurrentLocation": {
"description": "Tooltip para el botón de usar ubicación actual"
@@ -1339,15 +1209,6 @@
}
}
},
"copiedToClipboardShort": "Copiado {label} al portapapeles",
"@copiedToClipboardShort": {
"description": "Mensaje corto de éxito al copiar al portapapeles",
"placeholders": {
"label": {
"type": "String"
}
}
},
"failedToSave": "Error al guardar: {error}",
"@failedToSave": {
"description": "Mensaje de error genérico para fallos al guardar",
@@ -1367,7 +1228,6 @@
}
},
"sarTemplates": "Plantillas SAR",
"manageSarTemplates": "Gestionar plantillas SAR",
"addTemplate": "Agregar plantilla",
"editTemplate": "Editar plantilla",
"deleteTemplate": "Eliminar plantilla",
@@ -1395,7 +1255,6 @@
"noTemplates": "No hay plantillas disponibles",
"tapAddToCreate": "Toca + para crear tu primera plantilla",
"ok": "OK",
"permissionsSection": "Permisos",
"locationPermission": "Permiso de ubicación",
"checking": "Comprobando...",
"locationPermissionGrantedAlways": "Concedido (Siempre)",
@@ -1535,28 +1394,6 @@
"wizardNext": "Siguiente",
"wizardGetStarted": "Comenzar",
"wizardWelcomeTitle": "Bienvenido a MeshCore SAR",
"wizardWelcomeDescription": "Una poderosa herramienta de comunicación sin conexión para operaciones de búsqueda y rescate. Conéctese con su equipo usando tecnología de radio en malla cuando las redes tradicionales no estén disponibles.",
"wizardConnectingTitle": "Conectando a su Radio",
"wizardConnectingDescription": "Conecte su smartphone a un dispositivo de radio MeshCore vía Bluetooth para comenzar a comunicarse sin conexión.",
"wizardConnectingFeature1": "Buscar dispositivos MeshCore cercanos",
"wizardConnectingFeature2": "Emparejar con su radio vía Bluetooth",
"wizardConnectingFeature3": "Funciona completamente sin conexión - no se requiere internet",
"wizardChannelTitle": "Canales",
"wizardChannelDescription": "Transmita mensajes a todos en un canal, perfecto para anuncios y coordinación de todo el equipo.",
"wizardChannelFeature1": "Canal público para comunicación general del equipo",
"wizardChannelFeature2": "Cree canales personalizados para grupos específicos",
"wizardChannelFeature3": "Los mensajes se retransmiten automáticamente por la malla",
"wizardContactsTitle": "Contactos",
"wizardContactsDescription": "Los miembros de su equipo aparecen automáticamente cuando se unen a la red en malla. Envíeles mensajes directos o vea su ubicación.",
"wizardContactsFeature1": "Contactos descubiertos automáticamente",
"wizardContactsFeature2": "Enviar mensajes directos privados",
"wizardContactsFeature3": "Ver nivel de batería y hora de última vista",
"wizardMapTitle": "Mapa & Ubicación",
"wizardMapDescription": "Rastree a su equipo en tiempo real y marque ubicaciones importantes para operaciones de búsqueda y rescate.",
"wizardMapFeature1": "Marcadores SAR para personas encontradas, incendios y áreas de preparación",
"wizardMapFeature2": "Rastreo GPS en tiempo real de miembros del equipo",
"wizardMapFeature3": "Descargar mapas sin conexión para áreas remotas",
"wizardMapFeature4": "Dibujar formas y compartir información táctica",
"viewWelcomeTutorial": "Ver tutorial de bienvenida",
"allTeamContacts": "Todos los contactos del equipo",
"directMessagesInfo": "Mensajes directos con confirmaciones. Enviado a {count} miembros del equipo.",
@@ -1662,29 +1499,10 @@
"posttxDelay": "Retardo post-tx",
"bandpass": "Paso de banda",
"bandpassFilterVoice": "Filtro de paso de banda de voz",
"frequency": "Frecuencia",
"australia": "Australia",
"australiaNarrow": "Australia (Estrecho)",
"australiaQld": "Australia: QLD",
"australiaSaWa": "Australia: SA, WA",
"newZealand": "Nueva Zelanda",
"newZealandNarrow": "Nueva Zelanda (Estrecho)",
"switzerland": "Suiza",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "República Checa (Estrecho)",
"eu433mhzLongRange": "EU 433MHz (Largo Alcance)",
"euukDeprecated": "UE/RU (Obsoleto)",
"euukNarrow": "UE/RU (Estrecho)",
"usacanadaRecommended": "EE.UU./Canadá (Recomendado)",
"vietnamDeprecated": "Vietnam (Obsoleto)",
"vietnamNarrow": "Vietnam (Estrecho)",
"active": "Activo",
"addContact": "Añadir contacto",
"all": "Todo",
"autoResolve": "Resolver automáticamente",
"clearAllLabel": "Borrar todo",
"clearRelays": "Borrar repetidores",
"clearFilters": "Borrar filtros",
"clearRoute": "Borrar ruta",
"clearMessages": "Borrar mensajes",
@@ -1710,10 +1528,7 @@
"send": "Enviar",
"sendAnyway": "Enviar de todos modos",
"share": "Compartir",
"shareContact": "Compartir contacto",
"trace": "Traza",
"use": "Usar",
"useSelectedFrequency": "Usar frecuencia seleccionada",
"discovery": "Descubrimiento",
"discoverRepeaters": "Descubrir repetidores",
"discoverSensors": "Descubrir sensores",
@@ -1736,7 +1551,6 @@
"gpsModule": "Módulo GPS",
"liveTraffic": "Tráfico en vivo",
"repeatersMap": "Mapa de repetidores",
"spectrumScan": "Escaneo de espectro",
"blePacketLogs": "Registros de paquetes BLE",
"onlineTraceDatabase": "Base de datos de trazas",
"routePathByteSize": "Tamaño en bytes de la ruta",
@@ -1748,7 +1562,6 @@
"disableContacts": "Desactivar contactos",
"enableSensorsTab": "Habilitar pestaña Sensores",
"enableProfiles": "Habilitar perfiles",
"autoRouteRotation": "Rotación automática de ruta",
"nearestRepeaterFallback": "Repetidor más cercano como reserva",
"deleteAllStoredMessageHistory": "Eliminar todo el historial de mensajes",
"messageFontSize": "Tamaño de fuente de mensajes",
@@ -1756,18 +1569,13 @@
"showMapDebugInfo": "Mostrar info de depuración del mapa",
"openMapInFullscreen": "Abrir mapa en pantalla completa",
"showSarMarkersLabel": "Mostrar marcadores SAR",
"displaySarMarkersOnTheMainMap": "Mostrar marcadores SAR en el mapa principal",
"showAllContactTrailsLabel": "Mostrar todos los rastros de contactos",
"hideRepeatersOnMap": "Ocultar repetidores en el mapa",
"setMapScale": "Establecer escala del mapa",
"customMapScaleSaved": "Escala de mapa personalizada guardada",
"voiceBitrate": "Tasa de bits de voz",
"voiceCompressor": "Compresor de voz",
"balancesQuietAndLoudSpeechLevels": "Equilibra niveles de habla suaves y altos",
"voiceLimiter": "Limitador de voz",
"preventsClippingPeaksBeforeEncoding": "Previene recorte de picos antes de codificar",
"micAutoGain": "Ganancia automática del micrófono",
"letsTheRecorderAdjustInputLevel": "Permite al grabador ajustar el nivel de entrada",
"echoCancellation": "Cancelación de eco",
"noiseSuppression": "Supresión de ruido",
"trimSilenceInVoiceMessages": "Recortar silencio en mensajes de voz",
@@ -1809,13 +1617,10 @@
"staging": "Área de reunión",
"object": "Objeto",
"quiet": "Silencioso",
"moderate": "Moderado",
"busy": "Ocupado",
"spectrumScanReturnedNoCandidateFrequencies": "El escaneo de espectro no encontró frecuencias candidatas",
"searchMessages": "Buscar mensajes",
"sendImageFromGallery": "Enviar imagen desde galería",
"takePhoto": "Tomar foto",
"dmOnly": "Solo mensaje directo",
"allMessages": "Todos los mensajes",
"sendToPublicChannel": "¿Enviar al canal público?",
"selectMarkerTypeAndDestination": "Seleccione tipo de marcador y destino",
@@ -1834,8 +1639,6 @@
"wipeDevice": "Borrar dispositivo",
"destructiveDeviceActions": "Acciones destructivas del dispositivo.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Elija un preajuste o ajuste la configuración de radio.",
"chooseTheNameAndLocationThisDeviceShares": "Elija el nombre y la ubicación que comparte este dispositivo.",
"availableSpaceOnThisDevice": "Espacio disponible en este dispositivo.",
"used": "Usado",
"total": "Total",
"renameValue": "Renombrar valor",
@@ -1853,8 +1656,6 @@
"onlineOnly": "Solo en línea",
"inBoth": "En ambos",
"source": "Fuente",
"manualRouteEdit": "Edición manual de ruta",
"observedMeshRoute": "Ruta mesh observada",
"allMessagesCleared": "Todos los mensajes borrados",
"onlineTraceDatabaseCleared": "Base de datos de trazas borrada",
"packetLogsCleared": "Registros de paquetes borrados",
@@ -1885,5 +1686,194 @@
"captured": "Capturado",
"size": "Tamaño",
"noCustomChannelsToClear": "No hay canales personalizados que borrar.",
"noDeviceContactsToClear": "No hay contactos del dispositivo que borrar."
"noDeviceContactsToClear": "No hay contactos del dispositivo que borrar.",
"autoSend": "Envío automático",
"autoSendDescription": "Enviar por la ruta actual.",
"sendDirect": "Envío directo",
"sendDirectDescription": "Enviar directamente a este contacto.",
"sendFlood": "Envío por inundación",
"sendFloodDescription": "Enviar a través de todos los repetidores.",
"postConnectDiscoveryTitle": "¿Descubrir repetidores ahora?",
"postConnectDiscoveryDescription": "Ejecutar descubrimiento de repetidores justo después de conectarse para ver nodos MeshCore cercanos y agregarlos a su red más rápido.",
"aToZ": "A-Z",
"add": "Agregar",
"addAdditionalDetails": "Agregar detalles adicionales",
"addPeer": "Agregar par",
"addPeerManually": "Agregar par manualmente",
"addedToContacts": "{name} agregado a contactos",
"addedToSensors": "{name} agregado a Sensores",
"allowAll": "Permitir todos",
"anonymousRxStats": "Estadísticas RX anónimas",
"appearance": "Apariencia",
"ascii": "ASCII",
"baseTelemetry": "Telemetría base",
"blePin": "PIN BLE",
"buzzerAlerts": "Alertas de zumbador",
"cancelImageReceive": "Cancelar recepción de imagen",
"choose": "Elegir",
"chooseWhichTabsAndContactSectionsToShow": "Elige qué pestañas y secciones de contactos mostrar",
"clearCache": "Limpiar caché",
"clearHistory": "Limpiar historial",
"clearLiveView": "Limpiar vista en vivo",
"clearLogs": "Limpiar registros",
"clearOfflineCache": "¿Limpiar caché sin conexión?",
"clearOverlay": "Limpiar superposición",
"clearRouteOnlyAfterAllRetriesFail": "Limpiar ruta solo después de que fallen todos los reintentos",
"codecBitrateAndAudioProcessing": "Códec, tasa de bits y procesamiento de audio",
"connectByIpAddress": "Conectar por dirección IP",
"controlWhichAlertsYouReceive": "Controla qué alertas recibes",
"copyFullHex": "Copiar Hex completo",
"copyRawResponse": "Copiar respuesta sin procesar",
"couldNotLoadMetHistory": "No se pudo cargar el historial MET",
"customizeSensor": "Personalizar {name}",
"data": "Datos",
"deleteGroup": "Eliminar grupo",
"deleteStyleConfirm": "¿Eliminar {name}?",
"deleteThisDrawing": "¿Eliminar este {type}?",
"deny": "Denegar",
"deviceInfo": "Información del dispositivo",
"deviceSettings": "Configuración del dispositivo",
"deviceTimeSynced": "Hora del dispositivo sincronizada con este teléfono.",
"disableFastGpsPublishing": "Desactivar publicación rápida de GPS",
"displayMarkersAndTraceDatabase": "Pantalla, marcadores y base de datos de trazas",
"download": "Descargar",
"environmentalTelemetry": "Telemetría ambiental",
"errorCheckingForUpdates": "Error al buscar actualizaciones: {error}",
"errorGeneric": "Error: {error}",
"errorOpeningDownload": "Error al abrir la descarga: {error}",
"exportLogs": "Exportar registros",
"extraAggressiveAvifCompression": "Compresión AVIF extra agresiva",
"failedToAddContact": "Error al agregar contacto: {error}",
"failedToAddName": "Error al agregar {name}: {error}",
"failedToClearChannels": "Error al limpiar canales: {error}",
"failedToClearContacts": "Error al limpiar contactos: {error}",
"failedToConnectViaSerial": "Error al conectar por serial",
"failedToLoadPreviewImage": "Error al cargar imagen de vista previa: {error}",
"failedToLoadTrace": "Error al cargar traza: {error}",
"failedToSendSarMarker": "Error al enviar marcador SAR: {error}",
"failedToSetBuzzerMode": "Error al configurar modo de zumbador: {error}",
"failedToSetGpsMode": "Error al configurar modo GPS: {error}",
"failedToSyncDeviceTime": "Error al sincronizar hora del dispositivo: {error}",
"failedToWipeDeviceData": "Error al borrar datos del dispositivo: {error}",
"fastGpsTargetChannel": "Canal objetivo de GPS rápido",
"fastUpdatesThresholdsAndPermissions": "Actualizaciones rápidas, umbrales y permisos",
"fetchingCatalog": "Obteniendo catálogo...",
"fifteenMinutes": "15 minutos",
"fifteenSeconds": "15 segundos",
"filterByDirection": "Filtrar por dirección",
"finish": "Finalizar",
"fiveMinutes": "5 minutos",
"fiveSeconds": "5 segundos",
"fix": "Posición",
"gps": "GPS",
"gpsAdvertPolicy": "Política de anuncio GPS",
"gpsAndLocation": "GPS y ubicación",
"gpsDiagnostics": "Diagnósticos GPS",
"gpsSection": "GPS",
"help": "Ayuda",
"hex": "Hex",
"hidden": "Oculto",
"howTheRadioAutoAddsDiscoveredNodes": "Cómo la radio agrega automáticamente nodos descubiertos.",
"images": "Imágenes",
"importProfile": "Importar perfil",
"lastFix": "Última posición",
"latest": "Más reciente",
"latitude": "Latitud",
"loadASavedRegion": "Cargar una región guardada",
"loading": "Cargando",
"locationSharingHardwareAndUpdateInterval": "Compartir ubicación, hardware e intervalo de actualización.",
"locationTelemetry": "Telemetría de ubicación",
"lockDestination": "Bloquear destino",
"longitude": "Longitud",
"mapStyle": "Estilo de mapa",
"max": "Máx",
"maxZoom": "Zoom máximo",
"messaging": "Mensajería",
"metHistory": "Historial MET",
"min": "Mín",
"minZoom": "Zoom mínimo",
"moveDown": "Mover abajo",
"moveUp": "Mover arriba",
"multiAckMode": "Modo multi-ACK",
"multiDeviceWorkspaceManagement": "Gestión de espacio de trabajo multidispositivo",
"myLocation": "Mi ubicación",
"nameAndTelemetryShared": "Nombre y telemetría compartidos con otros dispositivos.",
"newVersionAvailable": "Nueva versión: {version}",
"newer": "Más nuevo",
"noNeighboursFound": "No se encontraron vecinos",
"noServersFound": "No se encontraron servidores",
"notSet": "Sin configurar",
"notifications": "Notificaciones",
"offZeroSeconds": "Apagado (0 s)",
"offlineMaps": "Mapas sin conexión",
"older": "Más antiguo",
"oneByteMode0": "1 byte (modo 0)",
"oneHour": "1 hora",
"oneMinute": "1 minuto",
"openPacketLogs": "Abrir registros de paquetes",
"packetTypeHelp": "Ayuda de tipo de paquete",
"pasteShareLinkOrHexAdvert": "Pega un enlace compartido o anuncio hex",
"pathHash": "Hash de ruta",
"pathSize": "Tamaño de ruta",
"ping": "Ping",
"polygon": "Polígono",
"rawResponseCopied": "Respuesta sin procesar copiada",
"resendThroughNearestRepeaterOnFailure": "Reenviar a través del repetidor más cercano en caso de fallo",
"resolveContact": "Resolver contacto",
"routingRetriesAndDestinationLock": "Enrutamiento, reintentos y bloqueo de destino",
"samples": "Muestras",
"satellites": "Satélites",
"saving": "Guardando...",
"search": "Buscar",
"searchByNameOrToken": "Buscar por nombre o token",
"searchDiscoveredNodes": "Buscar nodos descubiertos",
"searchInCurrentFilter": "Buscar en el filtro actual",
"searchLogs": "Buscar registros...",
"searchSensors": "Buscar sensores",
"searchThisSection": "Buscar en esta sección",
"selectedRelays": "Relés seleccionados",
"sensor": "Sensor",
"serialError": "Error serial: {error}",
"setPathForContact": "Establecer ruta para {name}",
"shareLiveGps": "Compartir GPS en vivo",
"shareMyTiles": "Compartir mis mosaicos",
"sharingLocationFromPhone": "Compartiendo ubicación en este canal desde el teléfono.",
"sharingLocationFromRadio": "Compartiendo ubicación en este canal desde la radio.",
"signalDbm": "Señal {rssi} dBm",
"sizeCompressionAndPreview": "Tamaño, compresión y vista previa",
"skip": "Omitir",
"smallerFileSize": "Tamaño de archivo más pequeño",
"sort": "Ordenar",
"stoppedSharingLocation": "Se dejó de compartir ubicación en este canal.",
"suppressNotificationsWhileInForeground": "Suprimir notificaciones en primer plano",
"tabsAndNavigation": "Pestañas y navegación",
"templatesAndTutorials": "Plantillas y tutoriales",
"tenMinutes": "10 minutos",
"tenSeconds": "10 segundos",
"testSendUpdate": "Probar envío de actualización",
"themeLanguageAndDisplayPreferences": "Tema, idioma y preferencias de visualización",
"thirtyMinutes": "30 minutos",
"thirtySeconds": "30 segundos",
"threeBytesMode2": "3 bytes (modo 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Estadísticas de tráfico, historial de mensajes y herramientas de desarrollo",
"twoBytesMode1": "2 bytes (modo 1)",
"twoMinutes": "2 minutos",
"undo": "Deshacer",
"updated": "Actualizado",
"useContactFlags": "Usar banderas de contacto",
"useSavedCoordinates": "Usar coordenadas guardadas",
"view": "Ver",
"viewNeighbours": "Ver vecinos",
"viewPublicStats": "Ver estadísticas públicas",
"setRegionScope": "Establecer ámbito de región",
"regionScope": "Ámbito de región",
"regionScopeNone": "Ninguno (global)",
"clearRegionScope": "Limpiar ámbito",
"regionScopeWarning": "Solo los repetidores que permitan esta región reenviarán.",
"discoverRegions": "Descubrir desde repetidores",
"discoveringRegions": "Descubriendo regiones...",
"enterRegionName": "Nombre de región (ej. auckland)",
"noRegionsFound": "No se encontraron regiones en este repetidor.",
"regionScopeSet": "Ámbito de región establecido a {name}",
"regionScopeCleared": "Ámbito de región limpiado"
}

View File

@@ -36,10 +36,6 @@
"@scanAgain": {
"description": "Bouton pour relancer la recherche BLE"
},
"tapToConnect": "Appuyez pour connecter",
"@tapToConnect": {
"description": "Texte de sous-titre pour l'appareil dans la liste de recherche"
},
"deviceNotConnected": "Appareil non connecté",
"@deviceNotConnected": {
"description": "Message d'erreur lorsque l'appareil n'est pas connecté"
@@ -133,18 +129,10 @@
"@showRxTxIndicators": {
"description": "Paramètre pour afficher les indicateurs RX/TX"
},
"displayPacketActivity": "Afficher les indicateurs d'activité des paquets dans la barre supérieure",
"@displayPacketActivity": {
"description": "Description du paramètre des indicateurs RX/TX"
},
"disableMap": "Désactiver la carte",
"@disableMap": {
"description": "Paramètre pour désactiver l'onglet carte"
},
"disableMapDescription": "Masquer l'onglet carte pour réduire la consommation de batterie",
"@disableMapDescription": {
"description": "Description du paramètre pour désactiver la carte"
},
"language": "Langue",
"@language": {
"description": "Libellé du paramètre de langue"
@@ -173,10 +161,6 @@
"@appVersion": {
"description": "Libellé de la version de l'application"
},
"appName": "Nom de l'application",
"@appName": {
"description": "Libellé du nom de l'application"
},
"aboutMeshCoreSar": "À propos de MeshCore SAR",
"@aboutMeshCoreSar": {
"description": "Titre de la boîte de dialogue À propos"
@@ -308,10 +292,6 @@
"@resetPath": {
"description": "Action pour réinitialiser le chemin du contact pour réacheminement"
},
"publicKeyCopied": "Clé publique copiée dans le presse-papiers",
"@publicKeyCopied": {
"description": "Message de succès lorsque la clé publique est copiée"
},
"copiedToClipboard": "{label} copié dans le presse-papiers",
"@copiedToClipboard": {
"description": "Message de succès lorsqu'une valeur est copiée dans le presse-papiers",
@@ -720,22 +700,6 @@
"@lastSeen": {
"description": "Libellé de la dernière vue"
},
"roomStatus": "État du salon",
"@roomStatus": {
"description": "En-tête de section de l'état du salon"
},
"loginStatus": "État de connexion",
"@loginStatus": {
"description": "Libellé de l'état de connexion"
},
"notLoggedIn": "Non connecté",
"@notLoggedIn": {
"description": "État non connecté"
},
"adminAccess": "Accès administrateur",
"@adminAccess": {
"description": "Libellé de l'accès administrateur"
},
"yes": "Oui",
"@yes": {
"description": "Réponse Oui"
@@ -748,14 +712,6 @@
"@permissions": {
"description": "Libellé des permissions"
},
"passwordSaved": "Mot de passe enregistré",
"@passwordSaved": {
"description": "Libellé du mot de passe enregistré"
},
"locationColon": "Position :",
"@locationColon": {
"description": "En-tête de section de position"
},
"telemetry": "Télémétrie",
"@telemetry": {
"description": "En-tête de section de télémétrie"
@@ -784,19 +740,6 @@
"@gpsTelemetry": {
"description": "Libellé du GPS depuis la télémétrie"
},
"updated": "Mis à jour",
"@updated": {
"description": "Libellé de l'horodatage de mise à jour"
},
"pathResetInfo": "Chemin réinitialisé pour {name}. Le prochain message trouvera un nouvel itinéraire.",
"@pathResetInfo": {
"description": "Message d'information après la réinitialisation du chemin",
"placeholders": {
"name": {
"type": "String"
}
}
},
"reLoginToRoom": "Se reconnecter au salon",
"@reLoginToRoom": {
"description": "Bouton pour se reconnecter au salon"
@@ -1114,10 +1057,6 @@
"@frequencyMHz": {
"description": "Libellé du champ de fréquence radio"
},
"frequencyExample": "ex. : 869,618",
"@frequencyExample": {
"description": "Texte d'aide exemple pour la fréquence"
},
"bandwidth": "Bande passante",
"@bandwidth": {
"description": "Libellé du menu déroulant de bande passante"
@@ -1221,47 +1160,6 @@
"@trailControls": {
"description": "Info-bulle des contrôles de la trace"
},
"contactTrails": "Traces des contacts",
"@contactTrails": {
"description": "En-tête de section des traces des contacts"
},
"showAllContactTrails": "Afficher toutes les traces des contacts",
"@showAllContactTrails": {
"description": "Étiquette du commutateur pour afficher toutes les traces des contacts"
},
"noContactsWithLocationHistory": "Aucun contact avec historique de localisation",
"@noContactsWithLocationHistory": {
"description": "Sous-titre lorsqu'il n'y a pas de contacts avec des traces"
},
"showingTrailsForContacts": "Affichage des traces pour {count} contacts",
"@showingTrailsForContacts": {
"description": "Sous-titre montrant le nombre de contacts avec des traces",
"placeholders": {
"count": {
"type": "int"
}
}
},
"individualContactTrails": "Traces individuelles des contacts",
"@individualContactTrails": {
"description": "Titre de l'élément extensible pour les traces individuelles des contacts"
},
"deviceInformation": "Informations sur l'appareil",
"@deviceInformation": {
"description": "En-tête de section des informations sur l'appareil"
},
"bleName": "Nom BLE",
"@bleName": {
"description": "Libellé du nom de l'appareil Bluetooth Low Energy"
},
"meshName": "Nom du maillage",
"@meshName": {
"description": "Libellé du nom du réseau maillé"
},
"notSet": "Non défini",
"@notSet": {
"description": "Libellé lorsqu'une valeur n'est pas définie"
},
"model": "Modèle",
"@model": {
"description": "Libellé du modèle d'appareil"
@@ -1270,14 +1168,6 @@
"@version": {
"description": "Libellé de la version"
},
"buildDate": "Date de compilation",
"@buildDate": {
"description": "Libellé de la date de compilation du micrologiciel"
},
"firmware": "Micrologiciel",
"@firmware": {
"description": "Libellé du micrologiciel"
},
"maxContacts": "Contacts max",
"@maxContacts": {
"description": "Libellé de la capacité maximale de contacts"
@@ -1290,26 +1180,6 @@
"@publicInfo": {
"description": "En-tête de section des informations publiques"
},
"meshNetworkName": "Nom du réseau maillé",
"@meshNetworkName": {
"description": "Libellé du champ du nom du réseau maillé"
},
"nameBroadcastInMesh": "Nom diffusé dans les annonces du maillage",
"@nameBroadcastInMesh": {
"description": "Texte d'aide pour le champ du nom du réseau maillé"
},
"telemetryAndLocationSharing": "Télémétrie et partage de position",
"@telemetryAndLocationSharing": {
"description": "Libellé de l'interrupteur de télémétrie et de partage de position"
},
"lat": "Lat",
"@lat": {
"description": "Libellé du champ de latitude (forme courte)"
},
"lon": "Lon",
"@lon": {
"description": "Libellé du champ de longitude (forme courte)"
},
"useCurrentLocation": "Utiliser la position actuelle",
"@useCurrentLocation": {
"description": "Info-bulle du bouton d'utilisation de la position actuelle"
@@ -1339,15 +1209,6 @@
}
}
},
"copiedToClipboardShort": "{label} copié dans le presse-papiers",
"@copiedToClipboardShort": {
"description": "Message de succès court lors de la copie dans le presse-papiers",
"placeholders": {
"label": {
"type": "String"
}
}
},
"failedToSave": "Échec de l'enregistrement : {error}",
"@failedToSave": {
"description": "Message d'erreur générique pour les échecs d'enregistrement",
@@ -1367,7 +1228,6 @@
}
},
"sarTemplates": "Modèles SAR",
"manageSarTemplates": "Gérer les modèles SAR",
"addTemplate": "Ajouter un modèle",
"editTemplate": "Modifier le modèle",
"deleteTemplate": "Supprimer le modèle",
@@ -1395,7 +1255,6 @@
"noTemplates": "Aucun modele disponible",
"tapAddToCreate": "Appuyez sur + pour creer votre premier modele",
"ok": "OK",
"permissionsSection": "Autorisations",
"locationPermission": "Autorisation de localisation",
"checking": "Vérification...",
"locationPermissionGrantedAlways": "Accordée (Toujours)",
@@ -1538,28 +1397,6 @@
"wizardNext": "Suivant",
"wizardGetStarted": "Commencer",
"wizardWelcomeTitle": "Bienvenue dans MeshCore SAR",
"wizardWelcomeDescription": "Un outil de communication hors ligne puissant pour les opérations de recherche et de sauvetage. Connectez-vous avec votre équipe en utilisant la technologie radio maillée lorsque les réseaux traditionnels ne sont pas disponibles.",
"wizardConnectingTitle": "Connexion à votre Radio",
"wizardConnectingDescription": "Connectez votre smartphone à un appareil radio MeshCore via Bluetooth pour commencer à communiquer hors ligne.",
"wizardConnectingFeature1": "Rechercher les appareils MeshCore à proximité",
"wizardConnectingFeature2": "Coupler avec votre radio via Bluetooth",
"wizardConnectingFeature3": "Fonctionne entièrement hors ligne - aucun internet requis",
"wizardChannelTitle": "Canaux",
"wizardChannelDescription": "Diffusez des messages à tous sur un canal, parfait pour les annonces et la coordination de toute l'équipe.",
"wizardChannelFeature1": "Canal public pour la communication générale de l'équipe",
"wizardChannelFeature2": "Créer des canaux personnalisés pour des groupes spécifiques",
"wizardChannelFeature3": "Les messages sont automatiquement relayés par le maillage",
"wizardContactsTitle": "Contacts",
"wizardContactsDescription": "Les membres de votre équipe apparaissent automatiquement lorsqu'ils rejoignent le réseau maillé. Envoyez-leur des messages directs ou consultez leur emplacement.",
"wizardContactsFeature1": "Contacts découverts automatiquement",
"wizardContactsFeature2": "Envoyer des messages directs privés",
"wizardContactsFeature3": "Voir le niveau de batterie et l'heure de dernière vue",
"wizardMapTitle": "Carte & Localisation",
"wizardMapDescription": "Suivez votre équipe en temps réel et marquez les emplacements importants pour les opérations de recherche et de sauvetage.",
"wizardMapFeature1": "Marqueurs SAR pour les personnes retrouvées, les incendies et les zones de rassemblement",
"wizardMapFeature2": "Suivi GPS en temps réel des membres de l'équipe",
"wizardMapFeature3": "Télécharger des cartes hors ligne pour les zones éloignées",
"wizardMapFeature4": "Dessiner des formes et partager des informations tactiques",
"viewWelcomeTutorial": "Voir le tutoriel de bienvenue",
"allTeamContacts": "Tous les contacts de l'équipe",
"directMessagesInfo": "Messages directs avec confirmations. Envoyé à {count} membres de l'équipe.",
@@ -1665,29 +1502,10 @@
"posttxDelay": "Délai post-émission",
"bandpass": "Passe-bande",
"bandpassFilterVoice": "Filtre passe-bande voix",
"frequency": "Fréquence",
"australia": "Australie",
"australiaNarrow": "Australie (Étroit)",
"australiaQld": "Australie: QLD",
"australiaSaWa": "Australie: SA, WA",
"newZealand": "Nouvelle-Zélande",
"newZealandNarrow": "Nouvelle-Zélande (Étroit)",
"switzerland": "Suisse",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "République tchèque (Étroit)",
"eu433mhzLongRange": "UE 433MHz (Longue Portée)",
"euukDeprecated": "UE/RU (Obsolète)",
"euukNarrow": "UE/RU (Étroit)",
"usacanadaRecommended": "USA/Canada (Recommandé)",
"vietnamDeprecated": "Vietnam (Obsolète)",
"vietnamNarrow": "Vietnam (Étroit)",
"active": "Actif",
"addContact": "Ajouter un contact",
"all": "Tout",
"autoResolve": "Résolution automatique",
"clearAllLabel": "Tout effacer",
"clearRelays": "Effacer les relais",
"clearFilters": "Effacer les filtres",
"clearRoute": "Effacer la route",
"clearMessages": "Effacer les messages",
@@ -1713,10 +1531,7 @@
"send": "Envoyer",
"sendAnyway": "Envoyer quand même",
"share": "Partager",
"shareContact": "Partager le contact",
"trace": "Trace",
"use": "Utiliser",
"useSelectedFrequency": "Utiliser la fréquence sélectionnée",
"discovery": "Découverte",
"discoverRepeaters": "Découvrir les répéteurs",
"discoverSensors": "Découvrir les capteurs",
@@ -1739,7 +1554,6 @@
"gpsModule": "Module GPS",
"liveTraffic": "Trafic en direct",
"repeatersMap": "Carte des répéteurs",
"spectrumScan": "Scan spectral",
"blePacketLogs": "Journaux de paquets BLE",
"onlineTraceDatabase": "Base de données de traces",
"routePathByteSize": "Taille en octets du chemin",
@@ -1751,7 +1565,6 @@
"disableContacts": "Désactiver les contacts",
"enableSensorsTab": "Activer l'onglet Capteurs",
"enableProfiles": "Activer les profils",
"autoRouteRotation": "Rotation automatique de route",
"nearestRepeaterFallback": "Répéteur le plus proche en repli",
"deleteAllStoredMessageHistory": "Supprimer tout l'historique",
"messageFontSize": "Taille de police des messages",
@@ -1759,18 +1572,13 @@
"showMapDebugInfo": "Afficher les infos de débogage",
"openMapInFullscreen": "Ouvrir la carte en plein écran",
"showSarMarkersLabel": "Afficher les marqueurs SAR",
"displaySarMarkersOnTheMainMap": "Afficher les marqueurs SAR sur la carte",
"showAllContactTrailsLabel": "Afficher toutes les traces de contacts",
"hideRepeatersOnMap": "Masquer les répéteurs sur la carte",
"setMapScale": "Définir l'échelle de la carte",
"customMapScaleSaved": "Échelle de carte personnalisée enregistrée",
"voiceBitrate": "Débit vocal",
"voiceCompressor": "Compresseur vocal",
"balancesQuietAndLoudSpeechLevels": "Équilibre les niveaux faibles et forts",
"voiceLimiter": "Limiteur vocal",
"preventsClippingPeaksBeforeEncoding": "Empêche l'écrêtage avant l'encodage",
"micAutoGain": "Gain auto du micro",
"letsTheRecorderAdjustInputLevel": "Laisse l'enregistreur ajuster le niveau d'entrée",
"echoCancellation": "Annulation d'écho",
"noiseSuppression": "Suppression du bruit",
"trimSilenceInVoiceMessages": "Couper le silence dans les messages vocaux",
@@ -1812,13 +1620,10 @@
"staging": "Zone de regroupement",
"object": "Objet",
"quiet": "Calme",
"moderate": "Modéré",
"busy": "Occupé",
"spectrumScanReturnedNoCandidateFrequencies": "Le scan spectral n'a trouvé aucune fréquence candidate",
"searchMessages": "Rechercher des messages",
"sendImageFromGallery": "Envoyer une image de la galerie",
"takePhoto": "Prendre une photo",
"dmOnly": "Message direct uniquement",
"allMessages": "Tous les messages",
"sendToPublicChannel": "Envoyer au canal public ?",
"selectMarkerTypeAndDestination": "Sélectionnez le type de marqueur et la destination",
@@ -1837,8 +1642,6 @@
"wipeDevice": "Effacer l'appareil",
"destructiveDeviceActions": "Actions destructives de l'appareil.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Choisissez un préréglage ou ajustez les paramètres radio.",
"chooseTheNameAndLocationThisDeviceShares": "Choisissez le nom et l'emplacement partagés par cet appareil.",
"availableSpaceOnThisDevice": "Espace disponible sur cet appareil.",
"used": "Utilisé",
"total": "Total",
"renameValue": "Renommer la valeur",
@@ -1856,8 +1659,6 @@
"onlineOnly": "En ligne uniquement",
"inBoth": "Dans les deux",
"source": "Source",
"manualRouteEdit": "Édition manuelle de route",
"observedMeshRoute": "Route mesh observée",
"allMessagesCleared": "Tous les messages effacés",
"onlineTraceDatabaseCleared": "Base de données de traces effacée",
"packetLogsCleared": "Journaux de paquets effacés",
@@ -1888,5 +1689,194 @@
"captured": "Capturé",
"size": "Taille",
"noCustomChannelsToClear": "Aucun canal personnalisé à effacer.",
"noDeviceContactsToClear": "Aucun contact de l'appareil à effacer."
"noDeviceContactsToClear": "Aucun contact de l'appareil à effacer.",
"autoSend": "Envoi automatique",
"autoSendDescription": "Envoyer via le chemin actuel.",
"sendDirect": "Envoi direct",
"sendDirectDescription": "Envoyer directement à ce contact.",
"sendFlood": "Envoi par inondation",
"sendFloodDescription": "Envoyer via tous les relais.",
"postConnectDiscoveryTitle": "Découvrir les relais maintenant ?",
"postConnectDiscoveryDescription": "Lancer la découverte des relais juste après la connexion pour voir les nœuds MeshCore à proximité et les ajouter plus rapidement à votre réseau.",
"aToZ": "A-Z",
"add": "Ajouter",
"addAdditionalDetails": "Ajouter des détails supplémentaires",
"addPeer": "Ajouter un pair",
"addPeerManually": "Ajouter un pair manuellement",
"addedToContacts": "{name} ajouté aux contacts",
"addedToSensors": "{name} ajouté aux capteurs",
"allowAll": "Tout autoriser",
"anonymousRxStats": "Statistiques RX anonymes",
"appearance": "Apparence",
"ascii": "ASCII",
"baseTelemetry": "Télémétrie de base",
"blePin": "PIN BLE",
"buzzerAlerts": "Alertes sonores",
"cancelImageReceive": "Annuler la réception d'image",
"choose": "Choisir",
"chooseWhichTabsAndContactSectionsToShow": "Choisir les onglets et sections de contacts à afficher",
"clearCache": "Vider le cache",
"clearHistory": "Effacer l'historique",
"clearLiveView": "Effacer la vue en direct",
"clearLogs": "Effacer les journaux",
"clearOfflineCache": "Vider le cache hors ligne ?",
"clearOverlay": "Effacer la superposition",
"clearRouteOnlyAfterAllRetriesFail": "Effacer la route uniquement après l'échec de toutes les tentatives",
"codecBitrateAndAudioProcessing": "Codec, débit et traitement audio",
"connectByIpAddress": "Connexion par adresse IP",
"controlWhichAlertsYouReceive": "Contrôler les alertes que vous recevez",
"copyFullHex": "Copier le Hex complet",
"copyRawResponse": "Copier la réponse brute",
"couldNotLoadMetHistory": "Impossible de charger l'historique MET",
"customizeSensor": "Personnaliser {name}",
"data": "Données",
"deleteGroup": "Supprimer le groupe",
"deleteStyleConfirm": "Supprimer {name} ?",
"deleteThisDrawing": "Supprimer ce {type} ?",
"deny": "Refuser",
"deviceInfo": "Infos de l'appareil",
"deviceSettings": "Paramètres de l'appareil",
"deviceTimeSynced": "Heure de l'appareil synchronisée avec ce téléphone.",
"disableFastGpsPublishing": "Désactiver la publication GPS rapide",
"displayMarkersAndTraceDatabase": "Affichage, marqueurs et base de données de traces",
"download": "Télécharger",
"environmentalTelemetry": "Télémétrie environnementale",
"errorCheckingForUpdates": "Erreur lors de la vérification des mises à jour : {error}",
"errorGeneric": "Erreur : {error}",
"errorOpeningDownload": "Erreur lors de l'ouverture du téléchargement : {error}",
"exportLogs": "Exporter les journaux",
"extraAggressiveAvifCompression": "Compression AVIF extra-agressive",
"failedToAddContact": "Échec de l'ajout du contact : {error}",
"failedToAddName": "Échec de l'ajout de {name} : {error}",
"failedToClearChannels": "Échec de l'effacement des canaux : {error}",
"failedToClearContacts": "Échec de l'effacement des contacts : {error}",
"failedToConnectViaSerial": "Échec de la connexion via série",
"failedToLoadPreviewImage": "Échec du chargement de l'aperçu : {error}",
"failedToLoadTrace": "Échec du chargement de la trace : {error}",
"failedToSendSarMarker": "Échec de l'envoi du marqueur SAR : {error}",
"failedToSetBuzzerMode": "Échec du réglage du mode buzzer : {error}",
"failedToSetGpsMode": "Échec du réglage du mode GPS : {error}",
"failedToSyncDeviceTime": "Échec de la synchronisation de l'heure de l'appareil : {error}",
"failedToWipeDeviceData": "Échec de l'effacement des données de l'appareil : {error}",
"fastGpsTargetChannel": "Canal cible GPS rapide",
"fastUpdatesThresholdsAndPermissions": "Mises à jour rapides, seuils et permissions",
"fetchingCatalog": "Récupération du catalogue...",
"fifteenMinutes": "15 minutes",
"fifteenSeconds": "15 secondes",
"filterByDirection": "Filtrer par direction",
"finish": "Terminer",
"fiveMinutes": "5 minutes",
"fiveSeconds": "5 secondes",
"fix": "Correctif",
"gps": "GPS",
"gpsAdvertPolicy": "Politique d'annonce GPS",
"gpsAndLocation": "GPS et localisation",
"gpsDiagnostics": "Diagnostics GPS",
"gpsSection": "GPS",
"help": "Aide",
"hex": "Hex",
"hidden": "Masqué",
"howTheRadioAutoAddsDiscoveredNodes": "Comment la radio ajoute automatiquement les nœuds découverts.",
"images": "Images",
"importProfile": "Importer un profil",
"lastFix": "Dernier correctif",
"latest": "Le plus récent",
"latitude": "Latitude",
"loadASavedRegion": "Charger une région enregistrée",
"loading": "Chargement",
"locationSharingHardwareAndUpdateInterval": "Partage de position, matériel et intervalle de mise à jour.",
"locationTelemetry": "Télémétrie de position",
"lockDestination": "Verrouiller la destination",
"longitude": "Longitude",
"mapStyle": "Style de carte",
"max": "Max",
"maxZoom": "Zoom max",
"messaging": "Messagerie",
"metHistory": "Historique MET",
"min": "Min",
"minZoom": "Zoom min",
"moveDown": "Déplacer vers le bas",
"moveUp": "Déplacer vers le haut",
"multiAckMode": "Mode multi-ACK",
"multiDeviceWorkspaceManagement": "Gestion d'espace de travail multi-appareils",
"myLocation": "Ma position",
"nameAndTelemetryShared": "Nom et télémétrie partagés avec d'autres appareils.",
"newVersionAvailable": "Nouvelle version : {version}",
"newer": "Plus récent",
"noNeighboursFound": "Aucun voisin trouvé",
"noServersFound": "Aucun serveur trouvé",
"notSet": "Non défini",
"notifications": "Notifications",
"offZeroSeconds": "Désactivé (0 s)",
"offlineMaps": "Cartes hors ligne",
"older": "Plus ancien",
"oneByteMode0": "1 octet (mode 0)",
"oneHour": "1 heure",
"oneMinute": "1 minute",
"openPacketLogs": "Ouvrir les journaux de paquets",
"packetTypeHelp": "Aide sur les types de paquets",
"pasteShareLinkOrHexAdvert": "Coller un lien de partage ou une annonce hex",
"pathHash": "Hash du chemin",
"pathSize": "Taille du chemin",
"ping": "Ping",
"polygon": "Polygone",
"rawResponseCopied": "Réponse brute copiée",
"resendThroughNearestRepeaterOnFailure": "Renvoyer via le répéteur le plus proche en cas d'échec",
"resolveContact": "Résoudre le contact",
"routingRetriesAndDestinationLock": "Routage, tentatives et verrouillage de destination",
"samples": "Échantillons",
"satellites": "Satellites",
"saving": "Enregistrement...",
"search": "Rechercher",
"searchByNameOrToken": "Rechercher par nom ou jeton",
"searchDiscoveredNodes": "Rechercher les nœuds découverts",
"searchInCurrentFilter": "Rechercher dans le filtre actuel",
"searchLogs": "Rechercher dans les journaux...",
"searchSensors": "Rechercher les capteurs",
"searchThisSection": "Rechercher dans cette section",
"selectedRelays": "Relais sélectionnés",
"sensor": "Capteur",
"serialError": "Erreur série : {error}",
"setPathForContact": "Définir le chemin pour {name}",
"shareLiveGps": "Partager le GPS en direct",
"shareMyTiles": "Partager mes tuiles",
"sharingLocationFromPhone": "Partage de la position sur ce canal depuis le téléphone.",
"sharingLocationFromRadio": "Partage de la position sur ce canal depuis la radio.",
"signalDbm": "Signal {rssi} dBm",
"sizeCompressionAndPreview": "Taille, compression et aperçu",
"skip": "Passer",
"smallerFileSize": "Taille de fichier réduite",
"sort": "Trier",
"stoppedSharingLocation": "Arrêt du partage de position sur ce canal.",
"suppressNotificationsWhileInForeground": "Supprimer les notifications en premier plan",
"tabsAndNavigation": "Onglets et navigation",
"templatesAndTutorials": "Modèles et tutoriels",
"tenMinutes": "10 minutes",
"tenSeconds": "10 secondes",
"testSendUpdate": "Tester l'envoi de mise à jour",
"themeLanguageAndDisplayPreferences": "Thème, langue et préférences d'affichage",
"thirtyMinutes": "30 minutes",
"thirtySeconds": "30 secondes",
"threeBytesMode2": "3 octets (mode 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Statistiques de trafic, historique des messages et outils de développement",
"twoBytesMode1": "2 octets (mode 1)",
"twoMinutes": "2 minutes",
"undo": "Annuler",
"updated": "Mis à jour",
"useContactFlags": "Utiliser les drapeaux de contact",
"useSavedCoordinates": "Utiliser les coordonnées enregistrées",
"view": "Voir",
"viewNeighbours": "Voir les voisins",
"viewPublicStats": "Voir les statistiques publiques",
"setRegionScope": "Définir la portée régionale",
"regionScope": "Portée régionale",
"regionScopeNone": "Aucune (globale)",
"clearRegionScope": "Effacer la portée",
"regionScopeWarning": "Seuls les relais autorisant cette région retransmettront.",
"discoverRegions": "Découvrir depuis les relais",
"discoveringRegions": "Découverte des régions...",
"enterRegionName": "Nom de région (ex. auckland)",
"noRegionsFound": "Aucune région trouvée sur ce relais.",
"regionScopeSet": "Portée régionale définie sur {name}",
"regionScopeCleared": "Portée régionale effacée"
}

View File

@@ -9,7 +9,6 @@
"disconnect": "Prekini",
"noDevicesFound": "Nisu pronađeni uređaji",
"scanAgain": "Skeniraj ponovno",
"tapToConnect": "Dodirnite za povezivanje",
"deviceNotConnected": "Uređaj nije povezan",
"locationPermissionDenied": "Dopuštenje za lokaciju odbijeno",
"locationPermissionPermanentlyDenied": "Dopuštenje za lokaciju trajno odbijeno. Molimo omogućite u Postavkama.",
@@ -32,9 +31,7 @@
"autoSystem": "Automatski (Sustav)",
"followSystemTheme": "Slijedi temu sustava",
"showRxTxIndicators": "Prikaži RX/TX indikatore",
"displayPacketActivity": "Prikaži indikatore aktivnosti paketa u gornjoj traci",
"disableMap": "Onemogući kartu",
"disableMapDescription": "Sakrij karticu karte za uštedu baterije",
"language": "Jezik",
"chooseLanguage": "Odaberite jezik",
"save": "Spremi",
@@ -42,7 +39,6 @@
"close": "Zatvori",
"about": "O aplikaciji",
"appVersion": "Verzija aplikacije",
"appName": "Ime aplikacije",
"aboutMeshCoreSar": "O MeshCore SAR",
"aboutDescription": "Aplikacija za potragu i spašavanje dizajnirana za timove za hitne slučajeve. Značajke uključuju:\n\n• BLE mesh mrežu za komunikaciju uređaj-uređaj\n• Offline karte s više slojeva\n• Praćenje članova tima u stvarnom vremenu\n• SAR taktički markeri (pronađena osoba, požar, zbirno mjesto)\n• Upravljanje kontaktima i razmjena poruka\n• GPS praćenje s kompasnim smjerom\n• Predmemoriranje karata za offline upotrebu",
"technologiesUsed": "Korištene tehnologije:",
@@ -69,7 +65,6 @@
"viewOnMap": "Prikaži na karti",
"refresh": "Osvježi",
"resetPath": "Resetiraj put (preusmjeri)",
"publicKeyCopied": "Javni ključ kopiran u međuspremnik",
"copiedToClipboard": "{label} kopirano u međuspremnik",
"pleaseEnterPassword": "Molimo unesite lozinku",
"failedToSyncContacts": "Neuspjela sinkronizacija kontakata: {error}",
@@ -148,15 +143,9 @@
"type": "Vrsta",
"publicKey": "Javni ključ",
"lastSeen": "Zadnje viđen",
"roomStatus": "Status sobe",
"loginStatus": "Status prijave",
"notLoggedIn": "Nije prijavljen",
"adminAccess": "Administratorski pristup",
"yes": "Da",
"no": "Ne",
"permissions": "Dopuštenja",
"passwordSaved": "Lozinka spremljena",
"locationColon": "Lokacija:",
"telemetry": "Telemetrija",
"voltage": "Napon",
"battery": "Baterija",
@@ -164,8 +153,6 @@
"humidity": "Vlažnost",
"pressure": "Tlak",
"gpsTelemetry": "GPS (Telemetrija)",
"updated": "Ažurirano",
"pathResetInfo": "Put resetiran za {name}. Sljedeća poruka će pronaći novu rutu.",
"reLoginToRoom": "Ponovna prijava u sobu",
"heading": "Smjer",
"elevation": "Nadmorska visina",
@@ -240,7 +227,6 @@
"tapToViewOnMap": "Dodirnite za prikaz na karti",
"radioSettings": "Postavke radija",
"frequencyMHz": "Frekvencija (MHz)",
"frequencyExample": "npr. 869.618",
"bandwidth": "Širina pojasa",
"spreadingFactor": "Faktor širenja",
"codingRate": "Omjer kodiranja",
@@ -263,38 +249,20 @@
"noTrailRecorded": "Još nije snimljena putanja",
"startTrackingToRecord": "Pokrenite praćenje lokacije za snimanje putanje",
"trailControls": "Upravljanje putanjom",
"contactTrails": "Putanje kontakata",
"showAllContactTrails": "Prikaži sve putanje kontakata",
"noContactsWithLocationHistory": "Nema kontakata s poviješću lokacije",
"showingTrailsForContacts": "Prikazujem putanje za {count} kontakata",
"individualContactTrails": "Pojedinačne putanje kontakata",
"deviceInformation": "Informacije o uređaju",
"bleName": "BLE naziv",
"meshName": "Mesh naziv",
"notSet": "Nije postavljeno",
"model": "Model",
"version": "Verzija",
"buildDate": "Datum izgradnje",
"firmware": "Firmver",
"maxContacts": "Maks. kontakata",
"maxChannels": "Maks. kanala",
"publicInfo": "Javne informacije",
"meshNetworkName": "Naziv mesh mreže",
"nameBroadcastInMesh": "Naziv koji se emitira u mesh oglasima",
"telemetryAndLocationSharing": "Telemetrija i dijeljenje lokacije",
"lat": "Šir",
"lon": "Duž",
"useCurrentLocation": "Koristi trenutnu lokaciju",
"noneUnknown": "Nema/Nepoznato",
"chatNode": "Čvorište za razgovor",
"repeater": "Repetitor",
"roomChannel": "Soba/Kanal",
"typeNumber": "Tip {number}",
"copiedToClipboardShort": "Kopirano {label} u međuspremnik",
"failedToSave": "Neuspjelo spremanje: {error}",
"failedToGetLocation": "Neuspjelo dohvaćanje lokacije: {error}",
"sarTemplates": "SAR predlošci",
"manageSarTemplates": "Upravljanje SAR predlošcima",
"addTemplate": "Dodaj predložak",
"editTemplate": "Uredi predložak",
"deleteTemplate": "Izbriši predložak",
@@ -322,7 +290,6 @@
"noTemplates": "Nema dostupnih predložaka",
"tapAddToCreate": "Dodirnite + za izradu prvog predloška",
"ok": "U redu",
"permissionsSection": "Dozvole",
"locationPermission": "Dozvola za lokaciju",
"checking": "Provjera...",
"locationPermissionGrantedAlways": "Odobreno (Uvijek)",
@@ -460,28 +427,6 @@
"wizardNext": "Dalje",
"wizardGetStarted": "Započni",
"wizardWelcomeTitle": "Dobrodošli u MeshCore SAR",
"wizardWelcomeDescription": "Moćan alat za komunikaciju izvan mreže za spasilačke operacije. Povežite se s timom uz mesh radijsku tehnologiju kada tradicionalne mreže nisu dostupne.",
"wizardConnectingTitle": "Povezivanje s radiom",
"wizardConnectingDescription": "Povežite telefon s MeshCore radijskim uređajem putem Bluetootha i započnite komunikaciju izvan mreže.",
"wizardConnectingFeature1": "Skenira obližnje MeshCore uređaje",
"wizardConnectingFeature2": "Uparivanje s radijem putem Bluetootha",
"wizardConnectingFeature3": "Radi potpuno izvan mreže — internet nije potreban",
"wizardChannelTitle": "Kanali",
"wizardChannelDescription": "Šaljite poruke svima na kanalu — idealno za obavijesti i koordinaciju tima.",
"wizardChannelFeature1": "Javni kanal za opću komunikaciju ekipe",
"wizardChannelFeature2": "Stvorite prilagođene kanale za specifične grupe",
"wizardChannelFeature3": "Poruke se automatski prosljeđuju putem mreže",
"wizardContactsTitle": "Kontakti",
"wizardContactsDescription": "Članovi tima se prikazuju automatski kada se pridruže mesh mreži. Šaljite im izravne poruke ili pogledajte njihovu lokaciju.",
"wizardContactsFeature1": "Kontakti se automatski otkrivaju",
"wizardContactsFeature2": "Šaljite privatne direktne poruke",
"wizardContactsFeature3": "Prikažite stanje baterije i vrijeme zadnje aktivnosti",
"wizardMapTitle": "Karta i lokacija",
"wizardMapDescription": "Pratite tim u stvarnom vremenu i označavajte ključne lokacije za spasilačke operacije.",
"wizardMapFeature1": "SAR oznake za pronađene osobe, požare i točke okupljanja",
"wizardMapFeature2": "GPS praćenje članova tima u stvarnom vremenu",
"wizardMapFeature3": "Preuzmite karte za rad izvan mreže",
"wizardMapFeature4": "Crtajte oblike i dijelite taktičke informacije",
"viewWelcomeTutorial": "Pogledaj uputu dobrodošlice",
"allTeamContacts": "Svi kontakti tima",
"directMessagesInfo": "Izravne poruke s potvrdom. Poslano {count} članovima tima.",
@@ -587,29 +532,10 @@
"posttxDelay": "Kašnjenje nakon slanja",
"bandpass": "Pojasni filtar",
"bandpassFilterVoice": "Pojasni filtar glasa",
"frequency": "Frekvencija",
"australia": "Australija",
"australiaNarrow": "Australija (usko)",
"australiaQld": "Australija: QLD",
"australiaSaWa": "Australija: SA, WA",
"newZealand": "Novi Zeland",
"newZealandNarrow": "Novi Zeland (usko)",
"switzerland": "Švicarska",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "Češka (usko)",
"eu433mhzLongRange": "EU 433MHz (veliki domet)",
"euukDeprecated": "EU/UK (zastarjelo)",
"euukNarrow": "EU/UK (usko)",
"usacanadaRecommended": "SAD/Kanada (preporučeno)",
"vietnamDeprecated": "Vijetnam (zastarjelo)",
"vietnamNarrow": "Vijetnam (usko)",
"active": "Aktivno",
"addContact": "Dodaj kontakt",
"all": "Sve",
"autoResolve": "Automatski razriješi",
"clearAllLabel": "Očisti sve",
"clearRelays": "Očisti posrednike",
"clearFilters": "Očisti filtre",
"clearRoute": "Očisti putanju",
"clearMessages": "Očisti poruke",
@@ -635,10 +561,7 @@
"send": "Pošalji",
"sendAnyway": "Svejedno pošalji",
"share": "Dijeli",
"shareContact": "Dijeli kontakt",
"trace": "Praćenje",
"use": "Koristi",
"useSelectedFrequency": "Koristi odabranu frekvenciju",
"discovery": "Otkrivanje",
"discoverRepeaters": "Otkrij posrednike",
"discoverSensors": "Otkrij senzore",
@@ -661,7 +584,6 @@
"gpsModule": "GPS modul",
"liveTraffic": "Promet uživo",
"repeatersMap": "Karta posrednika",
"spectrumScan": "Spektralno skeniranje",
"blePacketLogs": "BLE zapisi paketa",
"onlineTraceDatabase": "Baza mrežnih tragova",
"routePathByteSize": "Veličina putanje u bajtima",
@@ -673,7 +595,6 @@
"disableContacts": "Onemogući kontakte",
"enableSensorsTab": "Omogući karticu Senzori",
"enableProfiles": "Omogući profile",
"autoRouteRotation": "Automatsko rotiranje putanje",
"nearestRepeaterFallback": "Najbliži posrednik kao rezerva",
"deleteAllStoredMessageHistory": "Izbriši svu pohranjenu povijest poruka",
"messageFontSize": "Veličina fonta poruka",
@@ -681,18 +602,13 @@
"showMapDebugInfo": "Prikaži debug info karte",
"openMapInFullscreen": "Otvori kartu na cijelom zaslonu",
"showSarMarkersLabel": "Prikaži SAR oznake",
"displaySarMarkersOnTheMainMap": "Prikaži SAR oznake na glavnoj karti",
"showAllContactTrailsLabel": "Prikaži sve tragove kontakata",
"hideRepeatersOnMap": "Sakrij posrednike na karti",
"setMapScale": "Postavi mjerilo karte",
"customMapScaleSaved": "Prilagođeno mjerilo karte spremljeno",
"voiceBitrate": "Bitna brzina glasa",
"voiceCompressor": "Kompresor glasa",
"balancesQuietAndLoudSpeechLevels": "Izravnava tihe i glasne razine govora",
"voiceLimiter": "Ograničivač glasa",
"preventsClippingPeaksBeforeEncoding": "Sprječava rezanje vrhova prije kodiranja",
"micAutoGain": "Automatsko pojačanje mikrofona",
"letsTheRecorderAdjustInputLevel": "Dopušta snimaču prilagodbu razine ulaza",
"echoCancellation": "Poništavanje odjeka",
"noiseSuppression": "Potiskivanje šuma",
"trimSilenceInVoiceMessages": "Izreži tišinu u glasovnim porukama",
@@ -734,13 +650,10 @@
"staging": "Sabirno mjesto",
"object": "Objekt",
"quiet": "Tiho",
"moderate": "Umjereno",
"busy": "Zauzeto",
"spectrumScanReturnedNoCandidateFrequencies": "Spektralno skeniranje nije pronašlo prikladne frekvencije",
"searchMessages": "Pretraži poruke",
"sendImageFromGallery": "Pošalji sliku iz galerije",
"takePhoto": "Snimi fotografiju",
"dmOnly": "Samo izravno",
"allMessages": "Sve poruke",
"sendToPublicChannel": "Pošalji na javni kanal?",
"selectMarkerTypeAndDestination": "Odaberite vrstu oznake i odredište",
@@ -759,8 +672,6 @@
"wipeDevice": "Obriši uređaj",
"destructiveDeviceActions": "Destruktivne radnje na uređaju.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Odaberite predefiniranu postavku ili fino podesite radijske postavke.",
"chooseTheNameAndLocationThisDeviceShares": "Odaberite ime i lokaciju koju ovaj uređaj dijeli.",
"availableSpaceOnThisDevice": "Raspoloživi prostor na ovom uređaju.",
"used": "Korišteno",
"total": "Ukupno",
"renameValue": "Preimenuj vrijednost",
@@ -778,8 +689,6 @@
"onlineOnly": "Samo povezani",
"inBoth": "U oba",
"source": "Izvor",
"manualRouteEdit": "Ručno uređivanje putanje",
"observedMeshRoute": "Opažena mrežna putanja",
"allMessagesCleared": "Sve poruke očišćene",
"onlineTraceDatabaseCleared": "Baza mrežnih tragova očišćena",
"packetLogsCleared": "Zapisi paketa očišćeni",
@@ -810,5 +719,194 @@
"captured": "Zabilježeno",
"size": "Veličina",
"noCustomChannelsToClear": "Nema prilagođenih kanala za brisanje.",
"noDeviceContactsToClear": "Nema kontakata uređaja za brisanje."
"noDeviceContactsToClear": "Nema kontakata uređaja za brisanje.",
"autoSend": "Automatsko slanje",
"autoSendDescription": "Pošalji trenutnom rutom.",
"sendDirect": "Izravno slanje",
"sendDirectDescription": "Pošalji izravno ovom kontaktu.",
"sendFlood": "Slanje poplavom",
"sendFloodDescription": "Pošalji preko svih repetitora.",
"postConnectDiscoveryTitle": "Otkriti repetitore sada?",
"postConnectDiscoveryDescription": "Pokrenite otkrivanje repetitora odmah nakon povezivanja kako biste vidjeli obližnje MeshCore čvorove i brže ih dodali u svoju mrežu.",
"aToZ": "A-Ž",
"add": "Dodaj",
"addAdditionalDetails": "Dodaj dodatne detalje",
"addPeer": "Dodaj čvor",
"addPeerManually": "Ručno dodaj čvor",
"addedToContacts": "{name} dodan u kontakte",
"addedToSensors": "{name} dodan u Senzore",
"allowAll": "Dopusti sve",
"anonymousRxStats": "Anonimna RX statistika",
"appearance": "Izgled",
"ascii": "ASCII",
"baseTelemetry": "Osnovna telemetrija",
"blePin": "BLE PIN",
"buzzerAlerts": "Zvučna upozorenja",
"cancelImageReceive": "Otkaži primanje slike",
"choose": "Odaberi",
"chooseWhichTabsAndContactSectionsToShow": "Odaberite koje kartice i odjeljke kontakata prikazati",
"clearCache": "Očisti predmemoriju",
"clearHistory": "Očisti povijest",
"clearLiveView": "Očisti prikaz uživo",
"clearLogs": "Očisti zapise",
"clearOfflineCache": "Očistiti offline predmemoriju?",
"clearOverlay": "Očisti prekrivanje",
"clearRouteOnlyAfterAllRetriesFail": "Očisti rutu samo nakon što svi pokušaji ne uspiju",
"codecBitrateAndAudioProcessing": "Kodek, bitna brzina i obrada zvuka",
"connectByIpAddress": "Poveži se putem IP adrese",
"controlWhichAlertsYouReceive": "Upravljajte upozorenjima koja primate",
"copyFullHex": "Kopiraj cijeli Hex",
"copyRawResponse": "Kopiraj sirovi odgovor",
"couldNotLoadMetHistory": "Nije moguće učitati MET povijest",
"customizeSensor": "Prilagodi {name}",
"data": "Podaci",
"deleteGroup": "Izbriši grupu",
"deleteStyleConfirm": "Izbrisati {name}?",
"deleteThisDrawing": "Izbrisati ovaj {type}?",
"deny": "Odbij",
"deviceInfo": "Informacije o uređaju",
"deviceSettings": "Postavke uređaja",
"deviceTimeSynced": "Vrijeme uređaja sinkronizirano s ovim telefonom.",
"disableFastGpsPublishing": "Onemogući brzo GPS objavljivanje",
"displayMarkersAndTraceDatabase": "Prikaz, markeri i baza tragova",
"download": "Preuzmi",
"environmentalTelemetry": "Okolišna telemetrija",
"errorCheckingForUpdates": "Greška pri provjeri ažuriranja: {error}",
"errorGeneric": "Greška: {error}",
"errorOpeningDownload": "Greška pri otvaranju preuzimanja: {error}",
"exportLogs": "Izvezi zapise",
"extraAggressiveAvifCompression": "Dodatno agresivna AVIF kompresija",
"failedToAddContact": "Neuspjelo dodavanje kontakta: {error}",
"failedToAddName": "Neuspjelo dodavanje {name}: {error}",
"failedToClearChannels": "Neuspjelo brisanje kanala: {error}",
"failedToClearContacts": "Neuspjelo brisanje kontakata: {error}",
"failedToConnectViaSerial": "Neuspjelo povezivanje putem serijskog porta",
"failedToLoadPreviewImage": "Neuspjelo učitavanje slike za pregled: {error}",
"failedToLoadTrace": "Neuspjelo učitavanje traga: {error}",
"failedToSendSarMarker": "Neuspjelo slanje SAR markera: {error}",
"failedToSetBuzzerMode": "Neuspjelo postavljanje načina zujalice: {error}",
"failedToSetGpsMode": "Neuspjelo postavljanje GPS načina: {error}",
"failedToSyncDeviceTime": "Neuspjela sinkronizacija vremena uređaja: {error}",
"failedToWipeDeviceData": "Neuspjelo brisanje podataka uređaja: {error}",
"fastGpsTargetChannel": "Ciljni kanal brzog GPS-a",
"fastUpdatesThresholdsAndPermissions": "Brza ažuriranja, pragovi i dopuštenja",
"fetchingCatalog": "Dohvaćanje kataloga...",
"fifteenMinutes": "15 minuta",
"fifteenSeconds": "15 sekundi",
"filterByDirection": "Filtriraj po smjeru",
"finish": "Završi",
"fiveMinutes": "5 minuta",
"fiveSeconds": "5 sekundi",
"fix": "Fiksiranje",
"gps": "GPS",
"gpsAdvertPolicy": "Pravila GPS oglašavanja",
"gpsAndLocation": "GPS i lokacija",
"gpsDiagnostics": "GPS dijagnostika",
"gpsSection": "GPS",
"help": "Pomoć",
"hex": "Hex",
"hidden": "Skriveno",
"howTheRadioAutoAddsDiscoveredNodes": "Kako radio automatski dodaje otkrivene čvorove.",
"images": "Slike",
"importProfile": "Uvezi profil",
"lastFix": "Zadnje fiksiranje",
"latest": "Najnovije",
"latitude": "Geografska širina",
"loadASavedRegion": "Učitaj spremljenu regiju",
"loading": "Učitavanje",
"locationSharingHardwareAndUpdateInterval": "Dijeljenje lokacije, hardver i interval ažuriranja.",
"locationTelemetry": "Telemetrija lokacije",
"lockDestination": "Zaključaj odredište",
"longitude": "Geografska dužina",
"mapStyle": "Stil karte",
"max": "Maks",
"maxZoom": "Maks. zumiranje",
"messaging": "Poruke",
"metHistory": "MET povijest",
"min": "Min",
"minZoom": "Min. zumiranje",
"moveDown": "Pomakni dolje",
"moveUp": "Pomakni gore",
"multiAckMode": "Multi-ACK način",
"multiDeviceWorkspaceManagement": "Upravljanje radnim prostorom s više uređaja",
"myLocation": "Moja lokacija",
"nameAndTelemetryShared": "Ime i telemetrija dijeljeni s drugim uređajima.",
"newVersionAvailable": "Nova verzija: {version}",
"newer": "Novije",
"noNeighboursFound": "Nisu pronađeni susjedi",
"noServersFound": "Nisu pronađeni poslužitelji",
"notSet": "Nije postavljeno",
"notifications": "Obavijesti",
"offZeroSeconds": "Isključeno (0 s)",
"offlineMaps": "Offline karte",
"older": "Starije",
"oneByteMode0": "1 bajt (način 0)",
"oneHour": "1 sat",
"oneMinute": "1 minuta",
"openPacketLogs": "Otvori zapise paketa",
"packetTypeHelp": "Pomoć za vrstu paketa",
"pasteShareLinkOrHexAdvert": "Zalijepite dijeljenu vezu ili Hex oglas",
"pathHash": "Hash putanje",
"pathSize": "Veličina putanje",
"ping": "Ping",
"polygon": "Poligon",
"rawResponseCopied": "Sirovi odgovor kopiran",
"resendThroughNearestRepeaterOnFailure": "Ponovno pošalji kroz najbliži posrednik pri neuspjehu",
"resolveContact": "Razriješi kontakt",
"routingRetriesAndDestinationLock": "Usmjeravanje, pokušaji i zaključavanje odredišta",
"samples": "Uzorci",
"satellites": "Sateliti",
"saving": "Spremanje...",
"search": "Pretraži",
"searchByNameOrToken": "Pretraži po imenu ili tokenu",
"searchDiscoveredNodes": "Pretraži otkrivene čvorove",
"searchInCurrentFilter": "Pretraži u trenutnom filtru",
"searchLogs": "Pretraži zapise...",
"searchSensors": "Pretraži senzore",
"searchThisSection": "Pretraži ovaj odjeljak",
"selectedRelays": "Odabrani posrednici",
"sensor": "Senzor",
"serialError": "Serijska greška: {error}",
"setPathForContact": "Postavi putanju za {name}",
"shareLiveGps": "Dijeli GPS uživo",
"shareMyTiles": "Dijeli moje pločice",
"sharingLocationFromPhone": "Dijeljenje lokacije na ovom kanalu s telefona.",
"sharingLocationFromRadio": "Dijeljenje lokacije na ovom kanalu s radija.",
"signalDbm": "Signal {rssi} dBm",
"sizeCompressionAndPreview": "Veličina, kompresija i pregled",
"skip": "Preskoči",
"smallerFileSize": "Manja veličina datoteke",
"sort": "Sortiraj",
"stoppedSharingLocation": "Zaustavljeno dijeljenje lokacije na ovom kanalu.",
"suppressNotificationsWhileInForeground": "Utiši obavijesti dok je aplikacija u prvom planu",
"tabsAndNavigation": "Kartice i navigacija",
"templatesAndTutorials": "Predlošci i upute",
"tenMinutes": "10 minuta",
"tenSeconds": "10 sekundi",
"testSendUpdate": "Testno slanje ažuriranja",
"themeLanguageAndDisplayPreferences": "Tema, jezik i postavke prikaza",
"thirtyMinutes": "30 minuta",
"thirtySeconds": "30 sekundi",
"threeBytesMode2": "3 bajta (način 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Statistika prometa, povijest poruka i razvojni alati",
"twoBytesMode1": "2 bajta (način 1)",
"twoMinutes": "2 minute",
"undo": "Poništi",
"updated": "Ažurirano",
"useContactFlags": "Koristi zastavice kontakata",
"useSavedCoordinates": "Koristi spremljene koordinate",
"view": "Prikaži",
"viewNeighbours": "Prikaži susjede",
"viewPublicStats": "Prikaži javnu statistiku",
"setRegionScope": "Postavi opseg regije",
"regionScope": "Opseg regije",
"regionScopeNone": "Nema (globalno)",
"clearRegionScope": "Obriši opseg",
"regionScopeWarning": "Samo repetitori koji dopuštaju ovu regiju će prosljeđivati.",
"discoverRegions": "Otkrij iz repetitora",
"discoveringRegions": "Otkrivanje regija...",
"enterRegionName": "Naziv regije (npr. auckland)",
"noRegionsFound": "Nema pronađenih regija na ovom repetitoru.",
"regionScopeSet": "Opseg regije postavljen na {name}",
"regionScopeCleared": "Opseg regije obrisan"
}

View File

@@ -36,10 +36,6 @@
"@scanAgain": {
"description": "Pulsante per riavviare la ricerca BLE"
},
"tapToConnect": "Tocca per connettere",
"@tapToConnect": {
"description": "Testo sottotitolo per dispositivo nell'elenco di ricerca"
},
"deviceNotConnected": "Dispositivo non connesso",
"@deviceNotConnected": {
"description": "Messaggio di errore quando il dispositivo non è connesso"
@@ -133,18 +129,10 @@
"@showRxTxIndicators": {
"description": "Impostazione per mostrare gli indicatori RX/TX"
},
"displayPacketActivity": "Mostra indicatori di attività pacchetti nella barra superiore",
"@displayPacketActivity": {
"description": "Descrizione per l'impostazione degli indicatori RX/TX"
},
"disableMap": "Disabilita mappa",
"@disableMap": {
"description": "Impostazione per disabilitare la scheda mappa"
},
"disableMapDescription": "Nascondi la scheda mappa per ridurre il consumo della batteria",
"@disableMapDescription": {
"description": "Descrizione per l'impostazione di disabilitazione mappa"
},
"language": "Lingua",
"@language": {
"description": "Etichetta impostazione lingua"
@@ -173,10 +161,6 @@
"@appVersion": {
"description": "Etichetta versione app"
},
"appName": "Nome App",
"@appName": {
"description": "Etichetta nome app"
},
"aboutMeshCoreSar": "Informazioni su MeshCore SAR",
"@aboutMeshCoreSar": {
"description": "Titolo della finestra Informazioni"
@@ -308,10 +292,6 @@
"@resetPath": {
"description": "Azione per resettare il percorso del contatto per il re-instradamento"
},
"publicKeyCopied": "Chiave pubblica copiata negli appunti",
"@publicKeyCopied": {
"description": "Messaggio di successo quando la chiave pubblica viene copiata"
},
"copiedToClipboard": "{label} copiato negli appunti",
"@copiedToClipboard": {
"description": "Messaggio di successo quando un valore viene copiato negli appunti",
@@ -720,22 +700,6 @@
"@lastSeen": {
"description": "Etichetta ultima visita"
},
"roomStatus": "Stato Stanza",
"@roomStatus": {
"description": "Intestazione sezione stato stanza"
},
"loginStatus": "Stato Accesso",
"@loginStatus": {
"description": "Etichetta stato accesso"
},
"notLoggedIn": "Non Connesso",
"@notLoggedIn": {
"description": "Stato non connesso"
},
"adminAccess": "Accesso Admin",
"@adminAccess": {
"description": "Etichetta accesso amministratore"
},
"yes": "Sì",
"@yes": {
"description": "Risposta Sì"
@@ -748,14 +712,6 @@
"@permissions": {
"description": "Etichetta permessi"
},
"passwordSaved": "Password Salvata",
"@passwordSaved": {
"description": "Etichetta password salvata"
},
"locationColon": "Posizione:",
"@locationColon": {
"description": "Intestazione sezione posizione"
},
"telemetry": "Telemetria",
"@telemetry": {
"description": "Intestazione sezione telemetria"
@@ -784,19 +740,6 @@
"@gpsTelemetry": {
"description": "Etichetta GPS da telemetria"
},
"updated": "Aggiornato",
"@updated": {
"description": "Etichetta timestamp aggiornato"
},
"pathResetInfo": "Percorso resettato per {name}. Il prossimo messaggio troverà un nuovo instradamento.",
"@pathResetInfo": {
"description": "Messaggio informativo dopo il reset del percorso",
"placeholders": {
"name": {
"type": "String"
}
}
},
"reLoginToRoom": "Riaccedi alla Stanza",
"@reLoginToRoom": {
"description": "Pulsante per riaccedere alla stanza"
@@ -1114,10 +1057,6 @@
"@frequencyMHz": {
"description": "Etichetta per il campo frequenza radio"
},
"frequencyExample": "es., 869.618",
"@frequencyExample": {
"description": "Testo di aiuto esempio per la frequenza"
},
"bandwidth": "Larghezza di Banda",
"@bandwidth": {
"description": "Etichetta per il menu a discesa larghezza di banda"
@@ -1221,47 +1160,6 @@
"@trailControls": {
"description": "Tooltip controlli traccia"
},
"contactTrails": "Tracce contatti",
"@contactTrails": {
"description": "Intestazione sezione tracce contatti"
},
"showAllContactTrails": "Mostra tutte le tracce dei contatti",
"@showAllContactTrails": {
"description": "Etichetta dell'interruttore per mostrare tutte le tracce dei contatti"
},
"noContactsWithLocationHistory": "Nessun contatto con cronologia posizione",
"@noContactsWithLocationHistory": {
"description": "Sottotitolo quando non ci sono contatti con tracce"
},
"showingTrailsForContacts": "Visualizzazione tracce per {count} contatti",
"@showingTrailsForContacts": {
"description": "Sottotitolo che mostra il numero di contatti con tracce",
"placeholders": {
"count": {
"type": "int"
}
}
},
"individualContactTrails": "Tracce individuali dei contatti",
"@individualContactTrails": {
"description": "Titolo dell'elemento espandibile per le tracce individuali dei contatti"
},
"deviceInformation": "Informazioni Dispositivo",
"@deviceInformation": {
"description": "Intestazione sezione informazioni dispositivo"
},
"bleName": "Nome BLE",
"@bleName": {
"description": "Etichetta nome dispositivo Bluetooth Low Energy"
},
"meshName": "Nome Mesh",
"@meshName": {
"description": "Etichetta nome rete mesh"
},
"notSet": "Non impostato",
"@notSet": {
"description": "Etichetta quando un valore non è impostato"
},
"model": "Modello",
"@model": {
"description": "Etichetta modello dispositivo"
@@ -1270,14 +1168,6 @@
"@version": {
"description": "Etichetta versione"
},
"buildDate": "Data Build",
"@buildDate": {
"description": "Etichetta data build firmware"
},
"firmware": "Firmware",
"@firmware": {
"description": "Etichetta firmware"
},
"maxContacts": "Contatti Max",
"@maxContacts": {
"description": "Etichetta capacità massima contatti"
@@ -1290,26 +1180,6 @@
"@publicInfo": {
"description": "Intestazione sezione informazioni pubbliche"
},
"meshNetworkName": "Nome Rete Mesh",
"@meshNetworkName": {
"description": "Etichetta campo nome rete mesh"
},
"nameBroadcastInMesh": "Nome trasmesso negli annunci mesh",
"@nameBroadcastInMesh": {
"description": "Testo di aiuto per il campo nome rete mesh"
},
"telemetryAndLocationSharing": "Telemetria e Condivisione Posizione",
"@telemetryAndLocationSharing": {
"description": "Etichetta interruttore telemetria e condivisione posizione"
},
"lat": "Lat",
"@lat": {
"description": "Etichetta campo latitudine (forma breve)"
},
"lon": "Lon",
"@lon": {
"description": "Etichetta campo longitudine (forma breve)"
},
"useCurrentLocation": "Usa posizione attuale",
"@useCurrentLocation": {
"description": "Tooltip per il pulsante usa posizione attuale"
@@ -1339,15 +1209,6 @@
}
}
},
"copiedToClipboardShort": "Copiato {label} negli appunti",
"@copiedToClipboardShort": {
"description": "Messaggio di successo breve quando si copia negli appunti",
"placeholders": {
"label": {
"type": "String"
}
}
},
"failedToSave": "Impossibile salvare: {error}",
"@failedToSave": {
"description": "Messaggio di errore generico per fallimenti di salvataggio",
@@ -1367,7 +1228,6 @@
}
},
"sarTemplates": "Modelli SAR",
"manageSarTemplates": "Gestisci modelli SAR",
"addTemplate": "Aggiungi modello",
"editTemplate": "Modifica modello",
"deleteTemplate": "Elimina modello",
@@ -1395,7 +1255,6 @@
"noTemplates": "Nessun modello disponibile",
"tapAddToCreate": "Tocca + per creare il tuo primo modello",
"ok": "OK",
"permissionsSection": "Permessi",
"locationPermission": "Permesso di posizione",
"checking": "Verifica in corso...",
"locationPermissionGrantedAlways": "Concesso (Sempre)",
@@ -1538,28 +1397,6 @@
"wizardNext": "Avanti",
"wizardGetStarted": "Inizia",
"wizardWelcomeTitle": "Benvenuto in MeshCore SAR",
"wizardWelcomeDescription": "Un potente strumento di comunicazione offline per operazioni di ricerca e soccorso. Connettiti con il tuo team usando la tecnologia radio mesh quando le reti tradizionali non sono disponibili.",
"wizardConnectingTitle": "Connessione alla Radio",
"wizardConnectingDescription": "Collega il tuo smartphone a un dispositivo radio MeshCore tramite Bluetooth per iniziare a comunicare offline.",
"wizardConnectingFeature1": "Cerca dispositivi MeshCore nelle vicinanze",
"wizardConnectingFeature2": "Accoppia con la tua radio tramite Bluetooth",
"wizardConnectingFeature3": "Funziona completamente offline - non è richiesta connessione internet",
"wizardChannelTitle": "Canali",
"wizardChannelDescription": "Trasmetti messaggi a tutti su un canale, perfetto per annunci e coordinamento di tutto il team.",
"wizardChannelFeature1": "Canale pubblico per comunicazione generale del team",
"wizardChannelFeature2": "Crea canali personalizzati per gruppi specifici",
"wizardChannelFeature3": "I messaggi vengono automaticamente inoltrati attraverso la rete mesh",
"wizardContactsTitle": "Contatti",
"wizardContactsDescription": "I membri del tuo team appaiono automaticamente quando si uniscono alla rete mesh. Invia loro messaggi diretti o visualizza la loro posizione.",
"wizardContactsFeature1": "Contatti scoperti automaticamente",
"wizardContactsFeature2": "Invia messaggi diretti privati",
"wizardContactsFeature3": "Visualizza livello batteria e ultima volta visto",
"wizardMapTitle": "Mappa & Posizione",
"wizardMapDescription": "Traccia il tuo team in tempo reale e segna posizioni importanti per operazioni di ricerca e soccorso.",
"wizardMapFeature1": "Marcatori SAR per persone trovate, incendi e aree di staging",
"wizardMapFeature2": "Tracciamento GPS in tempo reale dei membri del team",
"wizardMapFeature3": "Scarica mappe offline per aree remote",
"wizardMapFeature4": "Disegna forme e condividi informazioni tattiche",
"viewWelcomeTutorial": "Visualizza tutorial di benvenuto",
"allTeamContacts": "Tutti i contatti del team",
"directMessagesInfo": "Messaggi diretti con conferme. Inviato a {count} membri del team.",
@@ -1665,29 +1502,10 @@
"posttxDelay": "Ritardo post-tx",
"bandpass": "Passa-banda",
"bandpassFilterVoice": "Filtro passa-banda voce",
"frequency": "Frequenza",
"australia": "Australia",
"australiaNarrow": "Australia (Stretto)",
"australiaQld": "Australia: QLD",
"australiaSaWa": "Australia: SA, WA",
"newZealand": "Nuova Zelanda",
"newZealandNarrow": "Nuova Zelanda (Stretto)",
"switzerland": "Svizzera",
"portugal433": "Portogallo 433",
"portugal868": "Portogallo 868",
"czechRepublicNarrow": "Repubblica Ceca (Stretto)",
"eu433mhzLongRange": "UE 433MHz (Lungo Raggio)",
"euukDeprecated": "UE/UK (Deprecato)",
"euukNarrow": "UE/UK (Stretto)",
"usacanadaRecommended": "USA/Canada (Consigliato)",
"vietnamDeprecated": "Vietnam (Deprecato)",
"vietnamNarrow": "Vietnam (Stretto)",
"active": "Attivo",
"addContact": "Aggiungi contatto",
"all": "Tutto",
"autoResolve": "Risolvi automaticamente",
"clearAllLabel": "Cancella tutto",
"clearRelays": "Cancella relay",
"clearFilters": "Cancella filtri",
"clearRoute": "Cancella percorso",
"clearMessages": "Cancella messaggi",
@@ -1713,10 +1531,7 @@
"send": "Invia",
"sendAnyway": "Invia comunque",
"share": "Condividi",
"shareContact": "Condividi contatto",
"trace": "Traccia",
"use": "Usa",
"useSelectedFrequency": "Usa frequenza selezionata",
"discovery": "Scoperta",
"discoverRepeaters": "Scopri ripetitori",
"discoverSensors": "Scopri sensori",
@@ -1739,7 +1554,6 @@
"gpsModule": "Modulo GPS",
"liveTraffic": "Traffico in tempo reale",
"repeatersMap": "Mappa ripetitori",
"spectrumScan": "Scansione spettro",
"blePacketLogs": "Log pacchetti BLE",
"onlineTraceDatabase": "Database tracce",
"routePathByteSize": "Dimensione percorso in byte",
@@ -1751,7 +1565,6 @@
"disableContacts": "Disabilita contatti",
"enableSensorsTab": "Abilita scheda Sensori",
"enableProfiles": "Abilita profili",
"autoRouteRotation": "Rotazione automatica percorso",
"nearestRepeaterFallback": "Ripetitore più vicino come fallback",
"deleteAllStoredMessageHistory": "Elimina tutta la cronologia messaggi",
"messageFontSize": "Dimensione carattere messaggi",
@@ -1759,18 +1572,13 @@
"showMapDebugInfo": "Mostra info debug mappa",
"openMapInFullscreen": "Apri mappa a schermo intero",
"showSarMarkersLabel": "Mostra marcatori SAR",
"displaySarMarkersOnTheMainMap": "Mostra marcatori SAR sulla mappa principale",
"showAllContactTrailsLabel": "Mostra tutti i percorsi contatti",
"hideRepeatersOnMap": "Nascondi ripetitori sulla mappa",
"setMapScale": "Imposta scala mappa",
"customMapScaleSaved": "Scala mappa personalizzata salvata",
"voiceBitrate": "Bitrate voce",
"voiceCompressor": "Compressore voce",
"balancesQuietAndLoudSpeechLevels": "Bilancia i livelli di voce bassi e alti",
"voiceLimiter": "Limitatore voce",
"preventsClippingPeaksBeforeEncoding": "Previene il clipping prima della codifica",
"micAutoGain": "Guadagno automatico microfono",
"letsTheRecorderAdjustInputLevel": "Permette al registratore di regolare il livello",
"echoCancellation": "Cancellazione eco",
"noiseSuppression": "Soppressione rumore",
"trimSilenceInVoiceMessages": "Taglia silenzio nei messaggi vocali",
@@ -1812,13 +1620,10 @@
"staging": "Area di raccolta",
"object": "Oggetto",
"quiet": "Silenzioso",
"moderate": "Moderato",
"busy": "Occupato",
"spectrumScanReturnedNoCandidateFrequencies": "La scansione spettro non ha trovato frequenze candidate",
"searchMessages": "Cerca messaggi",
"sendImageFromGallery": "Invia immagine dalla galleria",
"takePhoto": "Scatta foto",
"dmOnly": "Solo messaggio diretto",
"allMessages": "Tutti i messaggi",
"sendToPublicChannel": "Inviare al canale pubblico?",
"selectMarkerTypeAndDestination": "Seleziona tipo marcatore e destinazione",
@@ -1837,8 +1642,6 @@
"wipeDevice": "Cancella dispositivo",
"destructiveDeviceActions": "Azioni distruttive del dispositivo.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Scegli un preset o personalizza le impostazioni radio.",
"chooseTheNameAndLocationThisDeviceShares": "Scegli il nome e la posizione condivisi da questo dispositivo.",
"availableSpaceOnThisDevice": "Spazio disponibile su questo dispositivo.",
"used": "Usato",
"total": "Totale",
"renameValue": "Rinomina valore",
@@ -1856,8 +1659,6 @@
"onlineOnly": "Solo online",
"inBoth": "In entrambi",
"source": "Origine",
"manualRouteEdit": "Modifica manuale percorso",
"observedMeshRoute": "Percorso mesh osservato",
"allMessagesCleared": "Tutti i messaggi cancellati",
"onlineTraceDatabaseCleared": "Database tracce cancellata",
"packetLogsCleared": "Log pacchetti cancellati",
@@ -1888,5 +1689,194 @@
"captured": "Catturato",
"size": "Dimensione",
"noCustomChannelsToClear": "Nessun canale personalizzato da cancellare.",
"noDeviceContactsToClear": "Nessun contatto dispositivo da cancellare."
"noDeviceContactsToClear": "Nessun contatto dispositivo da cancellare.",
"autoSend": "Invio automatico",
"autoSendDescription": "Invia tramite il percorso attuale.",
"sendDirect": "Invio diretto",
"sendDirectDescription": "Invia direttamente a questo contatto.",
"sendFlood": "Invio flood",
"sendFloodDescription": "Invia tramite tutti i ripetitori.",
"postConnectDiscoveryTitle": "Scoprire i ripetitori ora?",
"postConnectDiscoveryDescription": "Esegui la scoperta dei ripetitori subito dopo la connessione per vedere i nodi MeshCore nelle vicinanze e aggiungerli alla tua rete più velocemente.",
"aToZ": "A-Z",
"add": "Aggiungi",
"addAdditionalDetails": "Aggiungi dettagli aggiuntivi",
"addPeer": "Aggiungi peer",
"addPeerManually": "Aggiungi peer manualmente",
"addedToContacts": "{name} aggiunto ai contatti",
"addedToSensors": "{name} aggiunto ai sensori",
"allowAll": "Consenti tutto",
"anonymousRxStats": "Statistiche RX anonime",
"appearance": "Aspetto",
"ascii": "ASCII",
"baseTelemetry": "Telemetria di base",
"blePin": "PIN BLE",
"buzzerAlerts": "Avvisi buzzer",
"cancelImageReceive": "Annulla ricezione immagine",
"choose": "Scegli",
"chooseWhichTabsAndContactSectionsToShow": "Scegli quali schede e sezioni contatti mostrare",
"clearCache": "Svuota cache",
"clearHistory": "Cancella cronologia",
"clearLiveView": "Cancella vista live",
"clearLogs": "Cancella log",
"clearOfflineCache": "Svuotare la cache offline?",
"clearOverlay": "Cancella sovrapposizione",
"clearRouteOnlyAfterAllRetriesFail": "Cancella il percorso solo dopo che tutti i tentativi falliscono",
"codecBitrateAndAudioProcessing": "Codec, bitrate ed elaborazione audio",
"connectByIpAddress": "Connetti tramite indirizzo IP",
"controlWhichAlertsYouReceive": "Controlla quali avvisi ricevi",
"copyFullHex": "Copia Hex completo",
"copyRawResponse": "Copia risposta grezza",
"couldNotLoadMetHistory": "Impossibile caricare la cronologia MET",
"customizeSensor": "Personalizza {name}",
"data": "Dati",
"deleteGroup": "Elimina gruppo",
"deleteStyleConfirm": "Eliminare {name}?",
"deleteThisDrawing": "Eliminare questo {type}?",
"deny": "Nega",
"deviceInfo": "Info dispositivo",
"deviceSettings": "Impostazioni dispositivo",
"deviceTimeSynced": "Ora del dispositivo sincronizzata con questo telefono.",
"disableFastGpsPublishing": "Disabilita pubblicazione GPS rapida",
"displayMarkersAndTraceDatabase": "Visualizzazione, marcatori e database tracce",
"download": "Scarica",
"environmentalTelemetry": "Telemetria ambientale",
"errorCheckingForUpdates": "Errore durante il controllo degli aggiornamenti: {error}",
"errorGeneric": "Errore: {error}",
"errorOpeningDownload": "Errore nell'apertura del download: {error}",
"exportLogs": "Esporta log",
"extraAggressiveAvifCompression": "Compressione AVIF extra aggressiva",
"failedToAddContact": "Impossibile aggiungere il contatto: {error}",
"failedToAddName": "Impossibile aggiungere {name}: {error}",
"failedToClearChannels": "Impossibile cancellare i canali: {error}",
"failedToClearContacts": "Impossibile cancellare i contatti: {error}",
"failedToConnectViaSerial": "Impossibile connettersi tramite seriale",
"failedToLoadPreviewImage": "Impossibile caricare l'anteprima: {error}",
"failedToLoadTrace": "Impossibile caricare la traccia: {error}",
"failedToSendSarMarker": "Impossibile inviare il marcatore SAR: {error}",
"failedToSetBuzzerMode": "Impossibile impostare la modalità buzzer: {error}",
"failedToSetGpsMode": "Impossibile impostare la modalità GPS: {error}",
"failedToSyncDeviceTime": "Impossibile sincronizzare l'ora del dispositivo: {error}",
"failedToWipeDeviceData": "Impossibile cancellare i dati del dispositivo: {error}",
"fastGpsTargetChannel": "Canale target GPS rapido",
"fastUpdatesThresholdsAndPermissions": "Aggiornamenti rapidi, soglie e permessi",
"fetchingCatalog": "Recupero catalogo...",
"fifteenMinutes": "15 minuti",
"fifteenSeconds": "15 secondi",
"filterByDirection": "Filtra per direzione",
"finish": "Fine",
"fiveMinutes": "5 minuti",
"fiveSeconds": "5 secondi",
"fix": "Fix",
"gps": "GPS",
"gpsAdvertPolicy": "Politica annunci GPS",
"gpsAndLocation": "GPS e posizione",
"gpsDiagnostics": "Diagnostica GPS",
"gpsSection": "GPS",
"help": "Aiuto",
"hex": "Hex",
"hidden": "Nascosto",
"howTheRadioAutoAddsDiscoveredNodes": "Come la radio aggiunge automaticamente i nodi scoperti.",
"images": "Immagini",
"importProfile": "Importa profilo",
"lastFix": "Ultimo fix",
"latest": "Più recente",
"latitude": "Latitudine",
"loadASavedRegion": "Carica una regione salvata",
"loading": "Caricamento",
"locationSharingHardwareAndUpdateInterval": "Condivisione posizione, hardware e intervallo di aggiornamento.",
"locationTelemetry": "Telemetria posizione",
"lockDestination": "Blocca destinazione",
"longitude": "Longitudine",
"mapStyle": "Stile mappa",
"max": "Max",
"maxZoom": "Zoom max",
"messaging": "Messaggistica",
"metHistory": "Cronologia MET",
"min": "Min",
"minZoom": "Zoom min",
"moveDown": "Sposta giù",
"moveUp": "Sposta su",
"multiAckMode": "Modalità multi-ACK",
"multiDeviceWorkspaceManagement": "Gestione spazio di lavoro multi-dispositivo",
"myLocation": "La mia posizione",
"nameAndTelemetryShared": "Nome e telemetria condivisi con altri dispositivi.",
"newVersionAvailable": "Nuova versione: {version}",
"newer": "Più recente",
"noNeighboursFound": "Nessun vicino trovato",
"noServersFound": "Nessun server trovato",
"notSet": "Non impostato",
"notifications": "Notifiche",
"offZeroSeconds": "Off (0 s)",
"offlineMaps": "Mappe offline",
"older": "Meno recente",
"oneByteMode0": "1 byte (modo 0)",
"oneHour": "1 ora",
"oneMinute": "1 minuto",
"openPacketLogs": "Apri log pacchetti",
"packetTypeHelp": "Guida tipi di pacchetto",
"pasteShareLinkOrHexAdvert": "Incolla un link di condivisione o annuncio Hex",
"pathHash": "Hash percorso",
"pathSize": "Dimensione percorso",
"ping": "Ping",
"polygon": "Poligono",
"rawResponseCopied": "Risposta grezza copiata",
"resendThroughNearestRepeaterOnFailure": "Rinvia tramite il ripetitore più vicino in caso di errore",
"resolveContact": "Risolvi contatto",
"routingRetriesAndDestinationLock": "Instradamento, tentativi e blocco destinazione",
"samples": "Campioni",
"satellites": "Satelliti",
"saving": "Salvataggio...",
"search": "Cerca",
"searchByNameOrToken": "Cerca per nome o token",
"searchDiscoveredNodes": "Cerca nodi scoperti",
"searchInCurrentFilter": "Cerca nel filtro corrente",
"searchLogs": "Cerca nei log...",
"searchSensors": "Cerca sensori",
"searchThisSection": "Cerca in questa sezione",
"selectedRelays": "Ripetitori selezionati",
"sensor": "Sensore",
"serialError": "Errore seriale: {error}",
"setPathForContact": "Imposta percorso per {name}",
"shareLiveGps": "Condividi GPS in tempo reale",
"shareMyTiles": "Condividi le mie tile",
"sharingLocationFromPhone": "Condivisione posizione su questo canale dal telefono.",
"sharingLocationFromRadio": "Condivisione posizione su questo canale dalla radio.",
"signalDbm": "Segnale {rssi} dBm",
"sizeCompressionAndPreview": "Dimensione, compressione e anteprima",
"skip": "Salta",
"smallerFileSize": "Dimensione file ridotta",
"sort": "Ordina",
"stoppedSharingLocation": "Condivisione posizione interrotta su questo canale.",
"suppressNotificationsWhileInForeground": "Disattiva notifiche quando l'app è in primo piano",
"tabsAndNavigation": "Schede e navigazione",
"templatesAndTutorials": "Modelli e tutorial",
"tenMinutes": "10 minuti",
"tenSeconds": "10 secondi",
"testSendUpdate": "Prova invio aggiornamento",
"themeLanguageAndDisplayPreferences": "Tema, lingua e preferenze di visualizzazione",
"thirtyMinutes": "30 minuti",
"thirtySeconds": "30 secondi",
"threeBytesMode2": "3 byte (modo 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Statistiche traffico, cronologia messaggi e strumenti sviluppatore",
"twoBytesMode1": "2 byte (modo 1)",
"twoMinutes": "2 minuti",
"undo": "Annulla",
"updated": "Aggiornato",
"useContactFlags": "Usa flag contatti",
"useSavedCoordinates": "Usa coordinate salvate",
"view": "Visualizza",
"viewNeighbours": "Visualizza vicini",
"viewPublicStats": "Visualizza statistiche pubbliche",
"setRegionScope": "Imposta ambito regionale",
"regionScope": "Ambito regionale",
"regionScopeNone": "Nessuno (globale)",
"clearRegionScope": "Cancella ambito",
"regionScopeWarning": "Solo i ripetitori che consentono questa regione inoltreranno.",
"discoverRegions": "Scopri dai ripetitori",
"discoveringRegions": "Scoperta delle regioni...",
"enterRegionName": "Nome regione (es. auckland)",
"noRegionsFound": "Nessuna regione trovata su questo ripetitore.",
"regionScopeSet": "Ambito regionale impostato su {name}",
"regionScopeCleared": "Ambito regionale cancellato"
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -36,10 +36,6 @@
"@scanAgain": {
"description": "Button to restart BLE scanning"
},
"tapToConnect": "Dotknij, aby połączyć",
"@tapToConnect": {
"description": "Subtitle text for device in scan list"
},
"deviceNotConnected": "Urządzenie nie jest połączone",
"@deviceNotConnected": {
"description": "Error message when device is not connected"
@@ -133,18 +129,10 @@
"@showRxTxIndicators": {
"description": "Setting to show RX/TX indicators"
},
"displayPacketActivity": "Wyświetlaj wskaźniki aktywności pakietów na górnym pasku",
"@displayPacketActivity": {
"description": "Description for RX/TX indicators setting"
},
"disableMap": "Wyłącz mapę",
"@disableMap": {
"description": "Setting to disable the map tab"
},
"disableMapDescription": "Ukryj kartę mapy, aby zmniejszyć zużycie baterii",
"@disableMapDescription": {
"description": "Description for disable map setting"
},
"language": "Język",
"@language": {
"description": "Language setting label"
@@ -173,10 +161,6 @@
"@appVersion": {
"description": "App version label"
},
"appName": "Nazwa aplikacji",
"@appName": {
"description": "App name label"
},
"aboutMeshCoreSar": "O MeshCore SAR",
"@aboutMeshCoreSar": {
"description": "About dialog title"
@@ -308,10 +292,6 @@
"@resetPath": {
"description": "Action to reset contact path for re-routing"
},
"publicKeyCopied": "Klucz publiczny skopiowano do schowka",
"@publicKeyCopied": {
"description": "Success message when public key is copied"
},
"copiedToClipboard": "Skopiowano {label} do schowka",
"@copiedToClipboard": {
"description": "Success message when a value is copied to clipboard",
@@ -720,22 +700,6 @@
"@lastSeen": {
"description": "Last seen label"
},
"roomStatus": "Status pokoju",
"@roomStatus": {
"description": "Room status section header"
},
"loginStatus": "Status logowania",
"@loginStatus": {
"description": "Login status label"
},
"notLoggedIn": "Niezalogowany",
"@notLoggedIn": {
"description": "Not logged in status"
},
"adminAccess": "Dostęp administratora",
"@adminAccess": {
"description": "Admin access label"
},
"yes": "Tak",
"@yes": {
"description": "Yes answer"
@@ -748,14 +712,6 @@
"@permissions": {
"description": "Permissions label"
},
"passwordSaved": "Hasło zapisano",
"@passwordSaved": {
"description": "Password saved label"
},
"locationColon": "Lokalizacja:",
"@locationColon": {
"description": "Location section header"
},
"telemetry": "Telemetria",
"@telemetry": {
"description": "Telemetry section header"
@@ -784,19 +740,6 @@
"@gpsTelemetry": {
"description": "GPS from telemetry label"
},
"updated": "Zaktualizowano",
"@updated": {
"description": "Updated timestamp label"
},
"pathResetInfo": "Trasa dla {name} została zresetowana. Następna wiadomość znajdzie nową trasę.",
"@pathResetInfo": {
"description": "Info message after path reset",
"placeholders": {
"name": {
"type": "String"
}
}
},
"reLoginToRoom": "Zaloguj ponownie do pokoju",
"@reLoginToRoom": {
"description": "Button to re-login to room"
@@ -1158,10 +1101,6 @@
"@frequencyMHz": {
"description": "Label for radio frequency field"
},
"frequencyExample": "np. 869.618",
"@frequencyExample": {
"description": "Helper text example for frequency"
},
"bandwidth": "Szerokość pasma",
"@bandwidth": {
"description": "Label for bandwidth dropdown"
@@ -1265,47 +1204,6 @@
"@trailControls": {
"description": "Trail controls tooltip"
},
"contactTrails": "Ślady kontaktów",
"@contactTrails": {
"description": "Contact trails section header"
},
"showAllContactTrails": "Pokaż wszystkie ślady kontaktów",
"@showAllContactTrails": {
"description": "Toggle label to show all contact trails"
},
"noContactsWithLocationHistory": "Brak kontaktów z historią lokalizacji",
"@noContactsWithLocationHistory": {
"description": "Subtitle when no contacts have trails"
},
"showingTrailsForContacts": "Pokazywane są ślady dla {count} kontaktów",
"@showingTrailsForContacts": {
"description": "Subtitle showing number of contacts with trails",
"placeholders": {
"count": {
"type": "int"
}
}
},
"individualContactTrails": "Indywidualne ślady kontaktów",
"@individualContactTrails": {
"description": "Expansion tile title for individual contact trails"
},
"deviceInformation": "Informacje o urządzeniu",
"@deviceInformation": {
"description": "Device information section header"
},
"bleName": "Nazwa BLE",
"@bleName": {
"description": "Bluetooth Low Energy device name label"
},
"meshName": "Nazwa mesh",
"@meshName": {
"description": "Mesh network name label"
},
"notSet": "Nie ustawiono",
"@notSet": {
"description": "Label when a value is not set"
},
"model": "Model",
"@model": {
"description": "Device model label"
@@ -1314,14 +1212,6 @@
"@version": {
"description": "Version label"
},
"buildDate": "Data kompilacji",
"@buildDate": {
"description": "Firmware build date label"
},
"firmware": "Firmware",
"@firmware": {
"description": "Firmware label"
},
"maxContacts": "Maks. kontaktów",
"@maxContacts": {
"description": "Maximum contacts capacity label"
@@ -1334,26 +1224,6 @@
"@publicInfo": {
"description": "Public information section header"
},
"meshNetworkName": "Nazwa sieci mesh",
"@meshNetworkName": {
"description": "Mesh network name field label"
},
"nameBroadcastInMesh": "Nazwa nadawana w ogłoszeniach mesh",
"@nameBroadcastInMesh": {
"description": "Helper text for mesh network name field"
},
"telemetryAndLocationSharing": "Telemetria i udostępnianie lokalizacji",
"@telemetryAndLocationSharing": {
"description": "Telemetry and location sharing toggle label"
},
"lat": "Szer.",
"@lat": {
"description": "Latitude field label (short form)"
},
"lon": "Dł.",
"@lon": {
"description": "Longitude field label (short form)"
},
"useCurrentLocation": "Użyj bieżącej lokalizacji",
"@useCurrentLocation": {
"description": "Tooltip for use current location button"
@@ -1383,15 +1253,6 @@
}
}
},
"copiedToClipboardShort": "Skopiowano {label} do schowka",
"@copiedToClipboardShort": {
"description": "Short success message when copying to clipboard",
"placeholders": {
"label": {
"type": "String"
}
}
},
"failedToSave": "Nie udało się zapisać: {error}",
"@failedToSave": {
"description": "Generic error message for save failures",
@@ -1414,10 +1275,6 @@
"@sarTemplates": {
"description": "SAR templates menu title"
},
"manageSarTemplates": "Zarządzaj szablonami SAR",
"@manageSarTemplates": {
"description": "Subtitle for SAR templates settings"
},
"addTemplate": "Dodaj szablon",
"@addTemplate": {
"description": "Button to add new SAR template"
@@ -1541,10 +1398,6 @@
"@ok": {
"description": "OK button label"
},
"permissionsSection": "Uprawnienia",
"@permissionsSection": {
"description": "Permissions section header"
},
"locationPermission": "Uprawnienie lokalizacji",
"@locationPermission": {
"description": "Location permission label"
@@ -2109,94 +1962,6 @@
"@wizardWelcomeTitle": {
"description": "Welcome wizard first page title"
},
"wizardWelcomeDescription": "Potężne narzędzie komunikacji poza siecią do operacji poszukiwawczo-ratowniczych. Połącz się ze swoim zespołem przy użyciu technologii radia mesh, gdy tradycyjne sieci są niedostępne.",
"@wizardWelcomeDescription": {
"description": "Welcome wizard first page description"
},
"wizardConnectingTitle": "Łączenie z radiem",
"@wizardConnectingTitle": {
"description": "Wizard connecting page title"
},
"wizardConnectingDescription": "Połącz smartfon z urządzeniem radiowym MeshCore przez Bluetooth, aby rozpocząć komunikację poza siecią.",
"@wizardConnectingDescription": {
"description": "Wizard connecting page description"
},
"wizardConnectingFeature1": "Skanuj pobliskie urządzenia MeshCore",
"@wizardConnectingFeature1": {
"description": "Wizard connecting feature 1"
},
"wizardConnectingFeature2": "Sparuj radio przez Bluetooth",
"@wizardConnectingFeature2": {
"description": "Wizard connecting feature 2"
},
"wizardConnectingFeature3": "Działa całkowicie offline - internet nie jest wymagany",
"@wizardConnectingFeature3": {
"description": "Wizard connecting feature 3"
},
"wizardChannelTitle": "Kanały",
"@wizardChannelTitle": {
"description": "Wizard channel page title"
},
"wizardChannelDescription": "Nadawaj wiadomości do wszystkich na kanale, idealne do ogłoszeń i koordynacji całego zespołu.",
"@wizardChannelDescription": {
"description": "Wizard channel page description"
},
"wizardChannelFeature1": "Kanał publiczny do ogólnej komunikacji zespołu",
"@wizardChannelFeature1": {
"description": "Wizard channel feature 1"
},
"wizardChannelFeature2": "Twórz własne kanały dla konkretnych grup",
"@wizardChannelFeature2": {
"description": "Wizard channel feature 2"
},
"wizardChannelFeature3": "Wiadomości są automatycznie przekazywane przez mesh",
"@wizardChannelFeature3": {
"description": "Wizard channel feature 3"
},
"wizardContactsTitle": "Kontakty",
"@wizardContactsTitle": {
"description": "Wizard contacts page title"
},
"wizardContactsDescription": "Członkowie zespołu pojawiają się automatycznie, gdy dołączają do sieci mesh. Wyślij im wiadomość bezpośrednią lub zobacz ich lokalizację.",
"@wizardContactsDescription": {
"description": "Wizard contacts page description"
},
"wizardContactsFeature1": "Kontakty wykrywane automatycznie",
"@wizardContactsFeature1": {
"description": "Wizard contacts feature 1"
},
"wizardContactsFeature2": "Wysyłaj prywatne wiadomości bezpośrednie",
"@wizardContactsFeature2": {
"description": "Wizard contacts feature 2"
},
"wizardContactsFeature3": "Podgląd poziomu baterii i czasu ostatniej aktywności",
"@wizardContactsFeature3": {
"description": "Wizard contacts feature 3"
},
"wizardMapTitle": "Mapa i lokalizacja",
"@wizardMapTitle": {
"description": "Wizard map page title"
},
"wizardMapDescription": "Śledź swój zespół w czasie rzeczywistym i oznaczaj ważne lokalizacje dla działań poszukiwawczo-ratowniczych.",
"@wizardMapDescription": {
"description": "Wizard map page description"
},
"wizardMapFeature1": "Znaczniki SAR dla odnalezionych osób, pożarów i stref zbiórki",
"@wizardMapFeature1": {
"description": "Wizard map feature 1"
},
"wizardMapFeature2": "Śledzenie GPS członków zespołu w czasie rzeczywistym",
"@wizardMapFeature2": {
"description": "Wizard map feature 2"
},
"wizardMapFeature3": "Pobieraj mapy offline dla odległych obszarów",
"@wizardMapFeature3": {
"description": "Wizard map feature 3"
},
"wizardMapFeature4": "Rysuj kształty i udostępniaj informacje taktyczne",
"@wizardMapFeature4": {
"description": "Wizard map feature 4"
},
"viewWelcomeTutorial": "Pokaż samouczek powitalny",
"@viewWelcomeTutorial": {
"description": "Settings option to re-show welcome wizard"
@@ -2352,29 +2117,10 @@
"posttxDelay": "Opóźnienie po nadawaniu",
"bandpass": "Pasmowy",
"bandpassFilterVoice": "Filtr pasmowy głosu",
"frequency": "Częstotliwość",
"australia": "Australia",
"australiaNarrow": "Australia (wąski)",
"australiaQld": "Australia: QLD",
"australiaSaWa": "Australia: SA, WA",
"newZealand": "Nowa Zelandia",
"newZealandNarrow": "Nowa Zelandia (wąski)",
"switzerland": "Szwajcaria",
"portugal433": "Portugalia 433",
"portugal868": "Portugalia 868",
"czechRepublicNarrow": "Czechy (wąski)",
"eu433mhzLongRange": "EU 433MHz (daleki zasięg)",
"euukDeprecated": "EU/UK (przestarzały)",
"euukNarrow": "EU/UK (wąski)",
"usacanadaRecommended": "USA/Kanada (zalecany)",
"vietnamDeprecated": "Wietnam (przestarzały)",
"vietnamNarrow": "Wietnam (wąski)",
"active": "Aktywny",
"addContact": "Dodaj kontakt",
"all": "Wszystko",
"autoResolve": "Automatyczne rozwiązanie",
"clearAllLabel": "Wyczyść wszystko",
"clearRelays": "Wyczyść przekaźniki",
"clearFilters": "Wyczyść filtry",
"clearRoute": "Wyczyść trasę",
"clearMessages": "Wyczyść wiadomości",
@@ -2400,10 +2146,7 @@
"send": "Wyślij",
"sendAnyway": "Wyślij mimo to",
"share": "Udostępnij",
"shareContact": "Udostępnij kontakt",
"trace": "Śledzenie",
"use": "Użyj",
"useSelectedFrequency": "Użyj wybranej częstotliwości",
"discovery": "Wykrywanie",
"discoverRepeaters": "Wykryj przekaźniki",
"discoverSensors": "Wykryj czujniki",
@@ -2426,7 +2169,6 @@
"gpsModule": "Moduł GPS",
"liveTraffic": "Ruch na żywo",
"repeatersMap": "Mapa przekaźników",
"spectrumScan": "Skanowanie spektrum",
"blePacketLogs": "Logi pakietów BLE",
"onlineTraceDatabase": "Baza śladów",
"routePathByteSize": "Rozmiar ścieżki w bajtach",
@@ -2438,7 +2180,6 @@
"disableContacts": "Wyłącz kontakty",
"enableSensorsTab": "Włącz kartę Czujniki",
"enableProfiles": "Włącz profile",
"autoRouteRotation": "Automatyczna rotacja trasy",
"nearestRepeaterFallback": "Najbliższy przekaźnik awaryjny",
"deleteAllStoredMessageHistory": "Usuń całą historię wiadomości",
"messageFontSize": "Rozmiar czcionki wiadomości",
@@ -2446,18 +2187,13 @@
"showMapDebugInfo": "Pokaż info debugowania mapy",
"openMapInFullscreen": "Otwórz mapę na pełnym ekranie",
"showSarMarkersLabel": "Pokaż znaczniki SAR",
"displaySarMarkersOnTheMainMap": "Wyświetlaj znaczniki SAR na głównej mapie",
"showAllContactTrailsLabel": "Pokaż wszystkie ślady kontaktów",
"hideRepeatersOnMap": "Ukryj przekaźniki na mapie",
"setMapScale": "Ustaw skalę mapy",
"customMapScaleSaved": "Niestandardowa skala mapy zapisana",
"voiceBitrate": "Bitrate głosu",
"voiceCompressor": "Kompresor głosu",
"balancesQuietAndLoudSpeechLevels": "Wyrównuje ciche i głośne poziomy mowy",
"voiceLimiter": "Limiter głosu",
"preventsClippingPeaksBeforeEncoding": "Zapobiega przycinaniu szczytów przed kodowaniem",
"micAutoGain": "Automatyczne wzmocnienie mikrofonu",
"letsTheRecorderAdjustInputLevel": "Pozwala nagrywarkowi dostosować poziom wejścia",
"echoCancellation": "Usuwanie echa",
"noiseSuppression": "Tłumienie szumu",
"trimSilenceInVoiceMessages": "Przytnij ciszę w wiadomościach głosowych",
@@ -2499,13 +2235,10 @@
"staging": "Punkt zbiórki",
"object": "Obiekt",
"quiet": "Cicho",
"moderate": "Umiarkowane",
"busy": "Zajęty",
"spectrumScanReturnedNoCandidateFrequencies": "Skanowanie spektrum nie znalazło częstotliwości kandydatów",
"searchMessages": "Szukaj wiadomości",
"sendImageFromGallery": "Wyślij obraz z galerii",
"takePhoto": "Zrób zdjęcie",
"dmOnly": "Tylko wiadomość bezpośrednia",
"allMessages": "Wszystkie wiadomości",
"sendToPublicChannel": "Wysłać na kanał publiczny?",
"selectMarkerTypeAndDestination": "Wybierz typ znacznika i cel",
@@ -2524,8 +2257,6 @@
"wipeDevice": "Wyczyść urządzenie",
"destructiveDeviceActions": "Destrukcyjne akcje na urządzeniu.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Wybierz ustawienie wstępne lub dostosuj ustawienia radia.",
"chooseTheNameAndLocationThisDeviceShares": "Wybierz nazwę i lokalizację udostępnianą przez to urządzenie.",
"availableSpaceOnThisDevice": "Dostępne miejsce na tym urządzeniu.",
"used": "Użyto",
"total": "Razem",
"renameValue": "Zmień nazwę wartości",
@@ -2543,8 +2274,6 @@
"onlineOnly": "Tylko online",
"inBoth": "W obu",
"source": "Źródło",
"manualRouteEdit": "Ręczna edycja trasy",
"observedMeshRoute": "Obserwowana trasa sieci",
"allMessagesCleared": "Wszystkie wiadomości wyczyszczone",
"onlineTraceDatabaseCleared": "Baza śladów wyczyszczona",
"packetLogsCleared": "Logi pakietów wyczyszczone",
@@ -2575,5 +2304,194 @@
"captured": "Przechwycone",
"size": "Rozmiar",
"noCustomChannelsToClear": "Brak niestandardowych kanałów do wyczyszczenia.",
"noDeviceContactsToClear": "Brak kontaktów urządzenia do wyczyszczenia."
"noDeviceContactsToClear": "Brak kontaktów urządzenia do wyczyszczenia.",
"autoSend": "Automatyczne wysyłanie",
"autoSendDescription": "Wyślij bieżącą trasą.",
"sendDirect": "Wysyłanie bezpośrednie",
"sendDirectDescription": "Wyślij bezpośrednio do tego kontaktu.",
"sendFlood": "Wysyłanie zalewowe",
"sendFloodDescription": "Wyślij przez wszystkie przekaźniki.",
"postConnectDiscoveryTitle": "Odkryć przekaźniki teraz?",
"postConnectDiscoveryDescription": "Uruchom odkrywanie przekaźników zaraz po połączeniu, aby zobaczyć pobliskie węzły MeshCore i szybciej dodać je do sieci.",
"aToZ": "A-Z",
"add": "Dodaj",
"addAdditionalDetails": "Dodaj dodatkowe szczegóły",
"addPeer": "Dodaj węzeł",
"addPeerManually": "Dodaj węzeł ręcznie",
"addedToContacts": "{name} dodano do kontaktów",
"addedToSensors": "{name} dodano do czujników",
"allowAll": "Zezwól na wszystko",
"anonymousRxStats": "Anonimowe statystyki RX",
"appearance": "Wygląd",
"ascii": "ASCII",
"baseTelemetry": "Telemetria bazowa",
"blePin": "PIN BLE",
"buzzerAlerts": "Alerty brzęczyka",
"cancelImageReceive": "Anuluj odbieranie obrazu",
"choose": "Wybierz",
"chooseWhichTabsAndContactSectionsToShow": "Wybierz, które zakładki i sekcje kontaktów mają być widoczne",
"clearCache": "Wyczyść pamięć podręczną",
"clearHistory": "Wyczyść historię",
"clearLiveView": "Wyczyść widok na żywo",
"clearLogs": "Wyczyść logi",
"clearOfflineCache": "Wyczyścić pamięć podręczną offline?",
"clearOverlay": "Wyczyść nakładkę",
"clearRouteOnlyAfterAllRetriesFail": "Wyczyść trasę dopiero po wyczerpaniu wszystkich prób",
"codecBitrateAndAudioProcessing": "Kodek, bitrate i przetwarzanie dźwięku",
"connectByIpAddress": "Połącz przez adres IP",
"controlWhichAlertsYouReceive": "Kontroluj, które alerty otrzymujesz",
"copyFullHex": "Kopiuj pełny Hex",
"copyRawResponse": "Kopiuj surową odpowiedź",
"couldNotLoadMetHistory": "Nie udało się wczytać historii MET",
"customizeSensor": "Dostosuj {name}",
"data": "Dane",
"deleteGroup": "Usuń grupę",
"deleteStyleConfirm": "Usunąć {name}?",
"deleteThisDrawing": "Usunąć ten {type}?",
"deny": "Odmów",
"deviceInfo": "Informacje o urządzeniu",
"deviceSettings": "Ustawienia urządzenia",
"deviceTimeSynced": "Czas urządzenia zsynchronizowany z tym telefonem.",
"disableFastGpsPublishing": "Wyłącz szybkie publikowanie GPS",
"displayMarkersAndTraceDatabase": "Wyświetlanie, znaczniki i baza danych tras",
"download": "Pobierz",
"environmentalTelemetry": "Telemetria środowiskowa",
"errorCheckingForUpdates": "Błąd sprawdzania aktualizacji: {error}",
"errorGeneric": "Błąd: {error}",
"errorOpeningDownload": "Błąd otwierania pobranego pliku: {error}",
"exportLogs": "Eksportuj logi",
"extraAggressiveAvifCompression": "Bardzo agresywna kompresja AVIF",
"failedToAddContact": "Nie udało się dodać kontaktu: {error}",
"failedToAddName": "Nie udało się dodać {name}: {error}",
"failedToClearChannels": "Nie udało się wyczyścić kanałów: {error}",
"failedToClearContacts": "Nie udało się wyczyścić kontaktów: {error}",
"failedToConnectViaSerial": "Nie udało się połączyć przez port szeregowy",
"failedToLoadPreviewImage": "Nie udało się wczytać podglądu obrazu: {error}",
"failedToLoadTrace": "Nie udało się wczytać trasy: {error}",
"failedToSendSarMarker": "Nie udało się wysłać znacznika SAR: {error}",
"failedToSetBuzzerMode": "Nie udało się ustawić trybu brzęczyka: {error}",
"failedToSetGpsMode": "Nie udało się ustawić trybu GPS: {error}",
"failedToSyncDeviceTime": "Nie udało się zsynchronizować czasu urządzenia: {error}",
"failedToWipeDeviceData": "Nie udało się wymazać danych urządzenia: {error}",
"fastGpsTargetChannel": "Docelowy kanał szybkiego GPS",
"fastUpdatesThresholdsAndPermissions": "Szybkie aktualizacje, progi i uprawnienia",
"fetchingCatalog": "Pobieranie katalogu...",
"fifteenMinutes": "15 minut",
"fifteenSeconds": "15 sekund",
"filterByDirection": "Filtruj według kierunku",
"finish": "Zakończ",
"fiveMinutes": "5 minut",
"fiveSeconds": "5 sekund",
"fix": "Napraw",
"gps": "GPS",
"gpsAdvertPolicy": "Zasady rozgłaszania GPS",
"gpsAndLocation": "GPS i lokalizacja",
"gpsDiagnostics": "Diagnostyka GPS",
"gpsSection": "GPS",
"help": "Pomoc",
"hex": "Hex",
"hidden": "Ukryte",
"howTheRadioAutoAddsDiscoveredNodes": "Jak radio automatycznie dodaje odkryte węzły.",
"images": "Obrazy",
"importProfile": "Importuj profil",
"lastFix": "Ostatni fix",
"latest": "Najnowsze",
"latitude": "Szerokość geograficzna",
"loadASavedRegion": "Wczytaj zapisany region",
"loading": "Ładowanie",
"locationSharingHardwareAndUpdateInterval": "Udostępnianie lokalizacji, sprzęt i interwał aktualizacji.",
"locationTelemetry": "Telemetria lokalizacji",
"lockDestination": "Zablokuj cel",
"longitude": "Długość geograficzna",
"mapStyle": "Styl mapy",
"max": "Maks.",
"maxZoom": "Maks. przybliżenie",
"messaging": "Wiadomości",
"metHistory": "Historia MET",
"min": "Min.",
"minZoom": "Min. przybliżenie",
"moveDown": "Przesuń w dół",
"moveUp": "Przesuń w górę",
"multiAckMode": "Tryb multi-ACK",
"multiDeviceWorkspaceManagement": "Zarządzanie przestrzenią roboczą wielu urządzeń",
"myLocation": "Moja lokalizacja",
"nameAndTelemetryShared": "Nazwa i telemetria udostępniana innym urządzeniom.",
"newVersionAvailable": "Nowa wersja: {version}",
"newer": "Nowsze",
"noNeighboursFound": "Nie znaleziono sąsiadów",
"noServersFound": "Nie znaleziono serwerów",
"notSet": "Nie ustawiono",
"notifications": "Powiadomienia",
"offZeroSeconds": "Wył. (0 s)",
"offlineMaps": "Mapy offline",
"older": "Starsze",
"oneByteMode0": "1 bajt (tryb 0)",
"oneHour": "1 godzina",
"oneMinute": "1 minuta",
"openPacketLogs": "Otwórz logi pakietów",
"packetTypeHelp": "Pomoc typów pakietów",
"pasteShareLinkOrHexAdvert": "Wklej link udostępniania lub ogłoszenie Hex",
"pathHash": "Hash ścieżki",
"pathSize": "Rozmiar ścieżki",
"ping": "Ping",
"polygon": "Wielokąt",
"rawResponseCopied": "Skopiowano surową odpowiedź",
"resendThroughNearestRepeaterOnFailure": "Ponów wysyłkę przez najbliższy przekaźnik po niepowodzeniu",
"resolveContact": "Rozwiąż kontakt",
"routingRetriesAndDestinationLock": "Routing, ponawianie i blokada celu",
"samples": "Próbki",
"satellites": "Satelity",
"saving": "Zapisywanie...",
"search": "Szukaj",
"searchByNameOrToken": "Szukaj po nazwie lub tokenie",
"searchDiscoveredNodes": "Szukaj odkrytych węzłów",
"searchInCurrentFilter": "Szukaj w bieżącym filtrze",
"searchLogs": "Szukaj w logach...",
"searchSensors": "Szukaj czujników",
"searchThisSection": "Szukaj w tej sekcji",
"selectedRelays": "Wybrane przekaźniki",
"sensor": "Czujnik",
"serialError": "Błąd portu szeregowego: {error}",
"setPathForContact": "Ustaw ścieżkę dla {name}",
"shareLiveGps": "Udostępnij GPS na żywo",
"shareMyTiles": "Udostępnij moje kafelki",
"sharingLocationFromPhone": "Udostępnianie lokalizacji na tym kanale z telefonu.",
"sharingLocationFromRadio": "Udostępnianie lokalizacji na tym kanale z radia.",
"signalDbm": "Sygnał {rssi} dBm",
"sizeCompressionAndPreview": "Rozmiar, kompresja i podgląd",
"skip": "Pomiń",
"smallerFileSize": "Mniejszy rozmiar pliku",
"sort": "Sortuj",
"stoppedSharingLocation": "Zatrzymano udostępnianie lokalizacji na tym kanale.",
"suppressNotificationsWhileInForeground": "Wycisz powiadomienia, gdy aplikacja jest na pierwszym planie",
"tabsAndNavigation": "Zakładki i nawigacja",
"templatesAndTutorials": "Szablony i samouczki",
"tenMinutes": "10 minut",
"tenSeconds": "10 sekund",
"testSendUpdate": "Testowe wysłanie aktualizacji",
"themeLanguageAndDisplayPreferences": "Motyw, język i preferencje wyświetlania",
"thirtyMinutes": "30 minut",
"thirtySeconds": "30 sekund",
"threeBytesMode2": "3 bajty (tryb 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Statystyki ruchu, historia wiadomości i narzędzia deweloperskie",
"twoBytesMode1": "2 bajty (tryb 1)",
"twoMinutes": "2 minuty",
"undo": "Cofnij",
"updated": "Zaktualizowano",
"useContactFlags": "Używaj flag kontaktów",
"useSavedCoordinates": "Użyj zapisanych współrzędnych",
"view": "Widok",
"viewNeighbours": "Pokaż sąsiadów",
"viewPublicStats": "Pokaż publiczne statystyki",
"setRegionScope": "Ustaw zakres regionu",
"regionScope": "Zakres regionu",
"regionScopeNone": "Brak (globalny)",
"clearRegionScope": "Wyczyść zakres",
"regionScopeWarning": "Tylko przekaźniki zezwalające na ten region będą przekazywać.",
"discoverRegions": "Odkryj z przekaźników",
"discoveringRegions": "Odkrywanie regionów...",
"enterRegionName": "Nazwa regionu (np. auckland)",
"noRegionsFound": "Nie znaleziono regionów na tym przekaźniku.",
"regionScopeSet": "Zakres regionu ustawiony na {name}",
"regionScopeCleared": "Zakres regionu wyczyszczony"
}

View File

@@ -36,10 +36,6 @@
"@scanAgain": {
"description": "Button to restart BLE scanning"
},
"tapToConnect": "Toque para conectar",
"@tapToConnect": {
"description": "Subtitle text for device in scan list"
},
"deviceNotConnected": "Dispositivo não conectado",
"@deviceNotConnected": {
"description": "Error message when device is not connected"
@@ -133,18 +129,10 @@
"@showRxTxIndicators": {
"description": "Setting to show RX/TX indicators"
},
"displayPacketActivity": "Exibir indicadores de atividade de pacotes na barra superior",
"@displayPacketActivity": {
"description": "Description for RX/TX indicators setting"
},
"disableMap": "Desativar mapa",
"@disableMap": {
"description": "Setting to disable the map tab"
},
"disableMapDescription": "Ocultar a aba de mapa para reduzir o uso de bateria",
"@disableMapDescription": {
"description": "Description for disable map setting"
},
"language": "Idioma",
"@language": {
"description": "Language setting label"
@@ -173,10 +161,6 @@
"@appVersion": {
"description": "App version label"
},
"appName": "Nome do app",
"@appName": {
"description": "App name label"
},
"aboutMeshCoreSar": "Sobre o MeshCore SAR",
"@aboutMeshCoreSar": {
"description": "About dialog title"
@@ -308,10 +292,6 @@
"@resetPath": {
"description": "Action to reset contact path for re-routing"
},
"publicKeyCopied": "Chave pública copiada para a área de transferência",
"@publicKeyCopied": {
"description": "Success message when public key is copied"
},
"copiedToClipboard": "{label} copiado para a área de transferência",
"@copiedToClipboard": {
"description": "Success message when a value is copied to clipboard",
@@ -720,22 +700,6 @@
"@lastSeen": {
"description": "Last seen label"
},
"roomStatus": "Status da sala",
"@roomStatus": {
"description": "Room status section header"
},
"loginStatus": "Status do login",
"@loginStatus": {
"description": "Login status label"
},
"notLoggedIn": "Não conectado",
"@notLoggedIn": {
"description": "Not logged in status"
},
"adminAccess": "Acesso de administrador",
"@adminAccess": {
"description": "Admin access label"
},
"yes": "Sim",
"@yes": {
"description": "Yes answer"
@@ -748,14 +712,6 @@
"@permissions": {
"description": "Permissions label"
},
"passwordSaved": "Senha salva",
"@passwordSaved": {
"description": "Password saved label"
},
"locationColon": "Localização:",
"@locationColon": {
"description": "Location section header"
},
"telemetry": "Telemetria",
"@telemetry": {
"description": "Telemetry section header"
@@ -784,19 +740,6 @@
"@gpsTelemetry": {
"description": "GPS from telemetry label"
},
"updated": "Atualizado",
"@updated": {
"description": "Updated timestamp label"
},
"pathResetInfo": "Rota redefinida para {name}. A próxima mensagem encontrará um novo caminho.",
"@pathResetInfo": {
"description": "Info message after path reset",
"placeholders": {
"name": {
"type": "String"
}
}
},
"reLoginToRoom": "Entrar novamente na sala",
"@reLoginToRoom": {
"description": "Button to re-login to room"
@@ -1158,10 +1101,6 @@
"@frequencyMHz": {
"description": "Label for radio frequency field"
},
"frequencyExample": "ex.: 869.618",
"@frequencyExample": {
"description": "Helper text example for frequency"
},
"bandwidth": "Largura de banda",
"@bandwidth": {
"description": "Label for bandwidth dropdown"
@@ -1265,47 +1204,6 @@
"@trailControls": {
"description": "Trail controls tooltip"
},
"contactTrails": "Trilhas dos contatos",
"@contactTrails": {
"description": "Contact trails section header"
},
"showAllContactTrails": "Mostrar todas as trilhas dos contatos",
"@showAllContactTrails": {
"description": "Toggle label to show all contact trails"
},
"noContactsWithLocationHistory": "Nenhum contato com histórico de localização",
"@noContactsWithLocationHistory": {
"description": "Subtitle when no contacts have trails"
},
"showingTrailsForContacts": "Mostrando trilhas de {count} contatos",
"@showingTrailsForContacts": {
"description": "Subtitle showing number of contacts with trails",
"placeholders": {
"count": {
"type": "int"
}
}
},
"individualContactTrails": "Trilhas individuais dos contatos",
"@individualContactTrails": {
"description": "Expansion tile title for individual contact trails"
},
"deviceInformation": "Informações do dispositivo",
"@deviceInformation": {
"description": "Device information section header"
},
"bleName": "Nome BLE",
"@bleName": {
"description": "Bluetooth Low Energy device name label"
},
"meshName": "Nome da mesh",
"@meshName": {
"description": "Mesh network name label"
},
"notSet": "Não definido",
"@notSet": {
"description": "Label when a value is not set"
},
"model": "Modelo",
"@model": {
"description": "Device model label"
@@ -1314,14 +1212,6 @@
"@version": {
"description": "Version label"
},
"buildDate": "Data de build",
"@buildDate": {
"description": "Firmware build date label"
},
"firmware": "Firmware",
"@firmware": {
"description": "Firmware label"
},
"maxContacts": "Máx. de contatos",
"@maxContacts": {
"description": "Maximum contacts capacity label"
@@ -1334,26 +1224,6 @@
"@publicInfo": {
"description": "Public information section header"
},
"meshNetworkName": "Nome da rede mesh",
"@meshNetworkName": {
"description": "Mesh network name field label"
},
"nameBroadcastInMesh": "Nome transmitido nos anúncios mesh",
"@nameBroadcastInMesh": {
"description": "Helper text for mesh network name field"
},
"telemetryAndLocationSharing": "Telemetria e compartilhamento de localização",
"@telemetryAndLocationSharing": {
"description": "Telemetry and location sharing toggle label"
},
"lat": "Lat.",
"@lat": {
"description": "Latitude field label (short form)"
},
"lon": "Lon.",
"@lon": {
"description": "Longitude field label (short form)"
},
"useCurrentLocation": "Usar localização atual",
"@useCurrentLocation": {
"description": "Tooltip for use current location button"
@@ -1383,15 +1253,6 @@
}
}
},
"copiedToClipboardShort": "{label} copiado para a área de transferência",
"@copiedToClipboardShort": {
"description": "Short success message when copying to clipboard",
"placeholders": {
"label": {
"type": "String"
}
}
},
"failedToSave": "Falha ao salvar: {error}",
"@failedToSave": {
"description": "Generic error message for save failures",
@@ -1414,10 +1275,6 @@
"@sarTemplates": {
"description": "SAR templates menu title"
},
"manageSarTemplates": "Gerenciar modelos SAR",
"@manageSarTemplates": {
"description": "Subtitle for SAR templates settings"
},
"addTemplate": "Adicionar modelo",
"@addTemplate": {
"description": "Button to add new SAR template"
@@ -1541,10 +1398,6 @@
"@ok": {
"description": "OK button label"
},
"permissionsSection": "Permissões",
"@permissionsSection": {
"description": "Permissions section header"
},
"locationPermission": "Permissão de localização",
"@locationPermission": {
"description": "Location permission label"
@@ -2109,94 +1962,6 @@
"@wizardWelcomeTitle": {
"description": "Welcome wizard first page title"
},
"wizardWelcomeDescription": "Uma poderosa ferramenta de comunicação off-grid para operações de busca e resgate. Conecte-se com sua equipe usando tecnologia de rádio mesh quando as redes tradicionais não estiverem disponíveis.",
"@wizardWelcomeDescription": {
"description": "Welcome wizard first page description"
},
"wizardConnectingTitle": "Conectando ao seu rádio",
"@wizardConnectingTitle": {
"description": "Wizard connecting page title"
},
"wizardConnectingDescription": "Conecte seu smartphone a um dispositivo de rádio MeshCore via Bluetooth para começar a se comunicar off-grid.",
"@wizardConnectingDescription": {
"description": "Wizard connecting page description"
},
"wizardConnectingFeature1": "Procure dispositivos MeshCore próximos",
"@wizardConnectingFeature1": {
"description": "Wizard connecting feature 1"
},
"wizardConnectingFeature2": "Emparelhe com seu rádio via Bluetooth",
"@wizardConnectingFeature2": {
"description": "Wizard connecting feature 2"
},
"wizardConnectingFeature3": "Funciona totalmente offline - internet não é necessária",
"@wizardConnectingFeature3": {
"description": "Wizard connecting feature 3"
},
"wizardChannelTitle": "Canais",
"@wizardChannelTitle": {
"description": "Wizard channel page title"
},
"wizardChannelDescription": "Transmita mensagens para todos em um canal, ideal para anúncios e coordenação de toda a equipe.",
"@wizardChannelDescription": {
"description": "Wizard channel page description"
},
"wizardChannelFeature1": "Canal público para comunicação geral da equipe",
"@wizardChannelFeature1": {
"description": "Wizard channel feature 1"
},
"wizardChannelFeature2": "Crie canais personalizados para grupos específicos",
"@wizardChannelFeature2": {
"description": "Wizard channel feature 2"
},
"wizardChannelFeature3": "As mensagens são retransmitidas automaticamente pela malha",
"@wizardChannelFeature3": {
"description": "Wizard channel feature 3"
},
"wizardContactsTitle": "Contatos",
"@wizardContactsTitle": {
"description": "Wizard contacts page title"
},
"wizardContactsDescription": "Os membros da sua equipe aparecem automaticamente à medida que entram na rede mesh. Envie mensagens diretas ou veja a localização deles.",
"@wizardContactsDescription": {
"description": "Wizard contacts page description"
},
"wizardContactsFeature1": "Contatos descobertos automaticamente",
"@wizardContactsFeature1": {
"description": "Wizard contacts feature 1"
},
"wizardContactsFeature2": "Envie mensagens diretas privadas",
"@wizardContactsFeature2": {
"description": "Wizard contacts feature 2"
},
"wizardContactsFeature3": "Veja o nível de bateria e o horário da última visualização",
"@wizardContactsFeature3": {
"description": "Wizard contacts feature 3"
},
"wizardMapTitle": "Mapa e localização",
"@wizardMapTitle": {
"description": "Wizard map page title"
},
"wizardMapDescription": "Acompanhe sua equipe em tempo real e marque locais importantes para operações de busca e resgate.",
"@wizardMapDescription": {
"description": "Wizard map page description"
},
"wizardMapFeature1": "Marcadores SAR para pessoas encontradas, incêndios e áreas de apoio",
"@wizardMapFeature1": {
"description": "Wizard map feature 1"
},
"wizardMapFeature2": "Rastreamento GPS em tempo real dos membros da equipe",
"@wizardMapFeature2": {
"description": "Wizard map feature 2"
},
"wizardMapFeature3": "Baixe mapas offline para áreas remotas",
"@wizardMapFeature3": {
"description": "Wizard map feature 3"
},
"wizardMapFeature4": "Desenhe formas e compartilhe informações táticas",
"@wizardMapFeature4": {
"description": "Wizard map feature 4"
},
"viewWelcomeTutorial": "Ver tutorial de boas-vindas",
"@viewWelcomeTutorial": {
"description": "Settings option to re-show welcome wizard"
@@ -2352,29 +2117,10 @@
"posttxDelay": "Atraso pós-tx",
"bandpass": "Passa-banda",
"bandpassFilterVoice": "Filtro passa-banda de voz",
"frequency": "Frequência",
"australia": "Austrália",
"australiaNarrow": "Austrália (Estreito)",
"australiaQld": "Austrália: QLD",
"australiaSaWa": "Austrália: SA, WA",
"newZealand": "Nova Zelândia",
"newZealandNarrow": "Nova Zelândia (Estreito)",
"switzerland": "Suíça",
"portugal433": "Portugal 433",
"portugal868": "Portugal 868",
"czechRepublicNarrow": "República Checa (Estreito)",
"eu433mhzLongRange": "UE 433MHz (Longo Alcance)",
"euukDeprecated": "UE/RU (Obsoleto)",
"euukNarrow": "UE/RU (Estreito)",
"usacanadaRecommended": "EUA/Canadá (Recomendado)",
"vietnamDeprecated": "Vietname (Obsoleto)",
"vietnamNarrow": "Vietname (Estreito)",
"active": "Ativo",
"addContact": "Adicionar contacto",
"all": "Tudo",
"autoResolve": "Resolver automaticamente",
"clearAllLabel": "Limpar tudo",
"clearRelays": "Limpar relays",
"clearFilters": "Limpar filtros",
"clearRoute": "Limpar rota",
"clearMessages": "Limpar mensagens",
@@ -2400,10 +2146,7 @@
"send": "Enviar",
"sendAnyway": "Enviar mesmo assim",
"share": "Partilhar",
"shareContact": "Partilhar contacto",
"trace": "Rastreio",
"use": "Usar",
"useSelectedFrequency": "Usar frequência selecionada",
"discovery": "Descoberta",
"discoverRepeaters": "Descobrir repetidores",
"discoverSensors": "Descobrir sensores",
@@ -2426,7 +2169,6 @@
"gpsModule": "Módulo GPS",
"liveTraffic": "Tráfego ao vivo",
"repeatersMap": "Mapa de repetidores",
"spectrumScan": "Varredura de espectro",
"blePacketLogs": "Registos de pacotes BLE",
"onlineTraceDatabase": "Base de dados de rastreio",
"routePathByteSize": "Tamanho do caminho em bytes",
@@ -2438,7 +2180,6 @@
"disableContacts": "Desativar contactos",
"enableSensorsTab": "Ativar separador Sensores",
"enableProfiles": "Ativar perfis",
"autoRouteRotation": "Rotação automática de rota",
"nearestRepeaterFallback": "Repetidor mais próximo como reserva",
"deleteAllStoredMessageHistory": "Apagar todo o histórico de mensagens",
"messageFontSize": "Tamanho da fonte de mensagens",
@@ -2446,18 +2187,13 @@
"showMapDebugInfo": "Mostrar info de depuração do mapa",
"openMapInFullscreen": "Abrir mapa em ecrã inteiro",
"showSarMarkersLabel": "Mostrar marcadores SAR",
"displaySarMarkersOnTheMainMap": "Mostrar marcadores SAR no mapa principal",
"showAllContactTrailsLabel": "Mostrar todos os rastos de contactos",
"hideRepeatersOnMap": "Ocultar repetidores no mapa",
"setMapScale": "Definir escala do mapa",
"customMapScaleSaved": "Escala de mapa personalizada guardada",
"voiceBitrate": "Taxa de bits de voz",
"voiceCompressor": "Compressor de voz",
"balancesQuietAndLoudSpeechLevels": "Equilibra níveis de fala suaves e altos",
"voiceLimiter": "Limitador de voz",
"preventsClippingPeaksBeforeEncoding": "Previne corte de picos antes da codificação",
"micAutoGain": "Ganho automático do microfone",
"letsTheRecorderAdjustInputLevel": "Permite ao gravador ajustar o nível de entrada",
"echoCancellation": "Cancelamento de eco",
"noiseSuppression": "Supressão de ruído",
"trimSilenceInVoiceMessages": "Cortar silêncio em mensagens de voz",
@@ -2499,13 +2235,10 @@
"staging": "Área de reunião",
"object": "Objeto",
"quiet": "Silencioso",
"moderate": "Moderado",
"busy": "Ocupado",
"spectrumScanReturnedNoCandidateFrequencies": "A varredura de espectro não encontrou frequências candidatas",
"searchMessages": "Pesquisar mensagens",
"sendImageFromGallery": "Enviar imagem da galeria",
"takePhoto": "Tirar foto",
"dmOnly": "Apenas mensagem direta",
"allMessages": "Todas as mensagens",
"sendToPublicChannel": "Enviar para o canal público?",
"selectMarkerTypeAndDestination": "Selecione tipo de marcador e destino",
@@ -2524,8 +2257,6 @@
"wipeDevice": "Apagar dispositivo",
"destructiveDeviceActions": "Ações destrutivas do dispositivo.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Escolha uma predefinição ou ajuste as definições de rádio.",
"chooseTheNameAndLocationThisDeviceShares": "Escolha o nome e a localização que este dispositivo partilha.",
"availableSpaceOnThisDevice": "Espaço disponível neste dispositivo.",
"used": "Usado",
"total": "Total",
"renameValue": "Renomear valor",
@@ -2543,8 +2274,6 @@
"onlineOnly": "Apenas online",
"inBoth": "Em ambos",
"source": "Fonte",
"manualRouteEdit": "Edição manual de rota",
"observedMeshRoute": "Rota mesh observada",
"allMessagesCleared": "Todas as mensagens limpas",
"onlineTraceDatabaseCleared": "Base de dados de rastreio limpa",
"packetLogsCleared": "Registos de pacotes limpos",
@@ -2575,5 +2304,194 @@
"captured": "Capturado",
"size": "Tamanho",
"noCustomChannelsToClear": "Sem canais personalizados para limpar.",
"noDeviceContactsToClear": "Sem contactos do dispositivo para limpar."
"noDeviceContactsToClear": "Sem contactos do dispositivo para limpar.",
"autoSend": "Envio automático",
"autoSendDescription": "Enviar pela rota atual.",
"sendDirect": "Envio direto",
"sendDirectDescription": "Enviar diretamente a este contacto.",
"sendFlood": "Envio por inundação",
"sendFloodDescription": "Enviar por todos os repetidores.",
"postConnectDiscoveryTitle": "Descobrir repetidores agora?",
"postConnectDiscoveryDescription": "Execute a descoberta de repetidores logo após a conexão para ver os nós MeshCore próximos e adicioná-los à sua rede mais rapidamente.",
"aToZ": "A-Z",
"add": "Adicionar",
"addAdditionalDetails": "Adicionar detalhes adicionais",
"addPeer": "Adicionar par",
"addPeerManually": "Adicionar par manualmente",
"addedToContacts": "{name} adicionado aos contatos",
"addedToSensors": "{name} adicionado aos Sensores",
"allowAll": "Permitir todos",
"anonymousRxStats": "Estatísticas RX anónimas",
"appearance": "Aparência",
"ascii": "ASCII",
"baseTelemetry": "Telemetria base",
"blePin": "PIN BLE",
"buzzerAlerts": "Alertas sonoros",
"cancelImageReceive": "Cancelar receção de imagem",
"choose": "Escolher",
"chooseWhichTabsAndContactSectionsToShow": "Escolher quais separadores e secções de contatos mostrar",
"clearCache": "Limpar cache",
"clearHistory": "Limpar histórico",
"clearLiveView": "Limpar vista ao vivo",
"clearLogs": "Limpar registos",
"clearOfflineCache": "Limpar cache offline?",
"clearOverlay": "Limpar sobreposição",
"clearRouteOnlyAfterAllRetriesFail": "Limpar rota apenas após todas as tentativas falharem",
"codecBitrateAndAudioProcessing": "Codec, bitrate e processamento de áudio",
"connectByIpAddress": "Conectar por endereço IP",
"controlWhichAlertsYouReceive": "Controlar quais alertas recebe",
"copyFullHex": "Copiar hex completo",
"copyRawResponse": "Copiar resposta bruta",
"couldNotLoadMetHistory": "Não foi possível carregar o histórico MET",
"customizeSensor": "Personalizar {name}",
"data": "Dados",
"deleteGroup": "Eliminar grupo",
"deleteStyleConfirm": "Eliminar {name}?",
"deleteThisDrawing": "Eliminar este {type}?",
"deny": "Negar",
"deviceInfo": "Informações do dispositivo",
"deviceSettings": "Definições do dispositivo",
"deviceTimeSynced": "Hora do dispositivo sincronizada com este telemóvel.",
"disableFastGpsPublishing": "Desativar publicação rápida de GPS",
"displayMarkersAndTraceDatabase": "Ecrã, marcadores e base de dados de percursos",
"download": "Transferir",
"environmentalTelemetry": "Telemetria ambiental",
"errorCheckingForUpdates": "Erro ao verificar atualizações: {error}",
"errorGeneric": "Erro: {error}",
"errorOpeningDownload": "Erro ao abrir transferência: {error}",
"exportLogs": "Exportar registos",
"extraAggressiveAvifCompression": "Compressão AVIF extra-agressiva",
"failedToAddContact": "Falha ao adicionar contato: {error}",
"failedToAddName": "Falha ao adicionar {name}: {error}",
"failedToClearChannels": "Falha ao limpar canais: {error}",
"failedToClearContacts": "Falha ao limpar contatos: {error}",
"failedToConnectViaSerial": "Falha ao conectar via série",
"failedToLoadPreviewImage": "Falha ao carregar imagem de pré-visualização: {error}",
"failedToLoadTrace": "Falha ao carregar percurso: {error}",
"failedToSendSarMarker": "Falha ao enviar marcador SAR: {error}",
"failedToSetBuzzerMode": "Falha ao definir modo de alarme: {error}",
"failedToSetGpsMode": "Falha ao definir modo GPS: {error}",
"failedToSyncDeviceTime": "Falha ao sincronizar hora do dispositivo: {error}",
"failedToWipeDeviceData": "Falha ao apagar dados do dispositivo: {error}",
"fastGpsTargetChannel": "Canal alvo de GPS rápido",
"fastUpdatesThresholdsAndPermissions": "Atualizações rápidas, limiares e permissões",
"fetchingCatalog": "A obter catálogo...",
"fifteenMinutes": "15 minutos",
"fifteenSeconds": "15 segundos",
"filterByDirection": "Filtrar por direção",
"finish": "Concluir",
"fiveMinutes": "5 minutos",
"fiveSeconds": "5 segundos",
"fix": "Correção",
"gps": "GPS",
"gpsAdvertPolicy": "Política de anúncio GPS",
"gpsAndLocation": "GPS e Localização",
"gpsDiagnostics": "Diagnósticos GPS",
"gpsSection": "GPS",
"help": "Ajuda",
"hex": "Hex",
"hidden": "Oculto",
"howTheRadioAutoAddsDiscoveredNodes": "Como o rádio adiciona automaticamente nós descobertos.",
"images": "Imagens",
"importProfile": "Importar perfil",
"lastFix": "Última correção",
"latest": "Mais recente",
"latitude": "Latitude",
"loadASavedRegion": "Carregar uma região guardada",
"loading": "A carregar",
"locationSharingHardwareAndUpdateInterval": "Partilha de localização, hardware e intervalo de atualização.",
"locationTelemetry": "Telemetria de localização",
"lockDestination": "Bloquear destino",
"longitude": "Longitude",
"mapStyle": "Estilo do mapa",
"max": "Máx",
"maxZoom": "Zoom máximo",
"messaging": "Mensagens",
"metHistory": "Histórico MET",
"min": "Mín",
"minZoom": "Zoom mínimo",
"moveDown": "Mover para baixo",
"moveUp": "Mover para cima",
"multiAckMode": "Modo multi-ACK",
"multiDeviceWorkspaceManagement": "Gestão de espaço de trabalho multi-dispositivo",
"myLocation": "A minha localização",
"nameAndTelemetryShared": "Nome e telemetria partilhados com outros dispositivos.",
"newVersionAvailable": "Nova versão: {version}",
"newer": "Mais recente",
"noNeighboursFound": "Nenhum vizinho encontrado",
"noServersFound": "Nenhum servidor encontrado",
"notSet": "Não definido",
"notifications": "Notificações",
"offZeroSeconds": "Desligado (0 s)",
"offlineMaps": "Mapas offline",
"older": "Mais antigo",
"oneByteMode0": "1 byte (modo 0)",
"oneHour": "1 hora",
"oneMinute": "1 minuto",
"openPacketLogs": "Abrir registos de pacotes",
"packetTypeHelp": "Ajuda sobre tipos de pacotes",
"pasteShareLinkOrHexAdvert": "Colar um link de partilha ou anúncio hex",
"pathHash": "Hash do caminho",
"pathSize": "Tamanho do caminho",
"ping": "Ping",
"polygon": "Polígono",
"rawResponseCopied": "Resposta bruta copiada",
"resendThroughNearestRepeaterOnFailure": "Reenviar pelo repetidor mais próximo em caso de falha",
"resolveContact": "Resolver contato",
"routingRetriesAndDestinationLock": "Encaminhamento, tentativas e bloqueio de destino",
"samples": "Amostras",
"satellites": "Satélites",
"saving": "A guardar...",
"search": "Pesquisar",
"searchByNameOrToken": "Pesquisar por nome ou token",
"searchDiscoveredNodes": "Pesquisar nós descobertos",
"searchInCurrentFilter": "Pesquisar no filtro atual",
"searchLogs": "Pesquisar registos...",
"searchSensors": "Pesquisar sensores",
"searchThisSection": "Pesquisar nesta secção",
"selectedRelays": "Repetidores selecionados",
"sensor": "Sensor",
"serialError": "Erro de série: {error}",
"setPathForContact": "Definir caminho para {name}",
"shareLiveGps": "Partilhar GPS ao vivo",
"shareMyTiles": "Partilhar os meus mosaicos",
"sharingLocationFromPhone": "A partilhar localização neste canal a partir do telemóvel.",
"sharingLocationFromRadio": "A partilhar localização neste canal a partir do rádio.",
"signalDbm": "Sinal {rssi} dBm",
"sizeCompressionAndPreview": "Tamanho, compressão e pré-visualização",
"skip": "Saltar",
"smallerFileSize": "Tamanho de ficheiro menor",
"sort": "Ordenar",
"stoppedSharingLocation": "Deixou de partilhar localização neste canal.",
"suppressNotificationsWhileInForeground": "Suprimir notificações em primeiro plano",
"tabsAndNavigation": "Separadores e Navegação",
"templatesAndTutorials": "Modelos e tutoriais",
"tenMinutes": "10 minutos",
"tenSeconds": "10 segundos",
"testSendUpdate": "Testar envio de atualização",
"themeLanguageAndDisplayPreferences": "Tema, idioma e preferências de apresentação",
"thirtyMinutes": "30 minutos",
"thirtySeconds": "30 segundos",
"threeBytesMode2": "3 bytes (modo 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Estatísticas de tráfego, histórico de mensagens e ferramentas de programador",
"twoBytesMode1": "2 bytes (modo 1)",
"twoMinutes": "2 minutos",
"undo": "Desfazer",
"updated": "Atualizado",
"useContactFlags": "Usar bandeiras de contato",
"useSavedCoordinates": "Usar coordenadas guardadas",
"view": "Ver",
"viewNeighbours": "Ver vizinhos",
"viewPublicStats": "Ver estatísticas públicas",
"setRegionScope": "Definir âmbito de região",
"regionScope": "Âmbito de região",
"regionScopeNone": "Nenhum (global)",
"clearRegionScope": "Limpar âmbito",
"regionScopeWarning": "Apenas os repetidores que permitem esta região irão retransmitir.",
"discoverRegions": "Descobrir a partir dos repetidores",
"discoveringRegions": "A descobrir regiões...",
"enterRegionName": "Nome da região (ex. auckland)",
"noRegionsFound": "Nenhuma região encontrada neste repetidor.",
"regionScopeSet": "Âmbito de região definido para {name}",
"regionScopeCleared": "Âmbito de região limpo"
}

View File

@@ -9,7 +9,6 @@
"disconnect": "Отключить",
"noDevicesFound": "Устройства не найдены",
"scanAgain": "Повторить поиск",
"tapToConnect": "Нажмите для подключения",
"deviceNotConnected": "Устройство не подключено",
"locationPermissionDenied": "Доступ к геолокации запрещён",
"locationPermissionPermanentlyDenied": "Доступ к геолокации запрещён навсегда. Включите его в настройках.",
@@ -32,9 +31,7 @@
"autoSystem": "Авто (Система)",
"followSystemTheme": "Следовать системной теме",
"showRxTxIndicators": "Показывать индикаторы RX/TX",
"displayPacketActivity": "Отображать активность пакетов в верхней панели",
"disableMap": "Отключить карту",
"disableMapDescription": "Скрыть вкладку карты для экономии заряда батареи",
"language": "Язык",
"chooseLanguage": "Выбрать язык",
"save": "Сохранить",
@@ -42,7 +39,6 @@
"close": "Закрыть",
"about": "О приложении",
"appVersion": "Версия приложения",
"appName": "Название приложения",
"aboutMeshCoreSar": "О MeshCore SAR",
"aboutDescription": "Приложение для поисково-спасательных операций, разработанное для аварийно-спасательных служб. Возможности:\n\n• BLE mesh-сеть для связи между устройствами\n• Офлайн-карты с несколькими вариантами слоёв\n• Отслеживание членов команды в реальном времени\n• Тактические маркеры SAR (найденный человек, пожар, место сбора)\n• Управление контактами и обмен сообщениями\n• GPS-трекинг с показанием курса компаса\n• Кэширование тайлов карт для работы офлайн",
"technologiesUsed": "Используемые технологии:",
@@ -69,7 +65,6 @@
"viewOnMap": "Показать на карте",
"refresh": "Обновить",
"resetPath": "Сбросить маршрут (перепроложить)",
"publicKeyCopied": "Публичный ключ скопирован в буфер обмена",
"copiedToClipboard": "{label} скопировано в буфер обмена",
"pleaseEnterPassword": "Пожалуйста, введите пароль",
"failedToSyncContacts": "Не удалось синхронизировать контакты: {error}",
@@ -149,15 +144,9 @@
"type": "Тип",
"publicKey": "Публичный ключ",
"lastSeen": "Последнее появление",
"roomStatus": "Статус комнаты",
"loginStatus": "Статус входа",
"notLoggedIn": "Не выполнен вход",
"adminAccess": "Доступ администратора",
"yes": "Да",
"no": "Нет",
"permissions": "Разрешения",
"passwordSaved": "Пароль сохранён",
"locationColon": "Местоположение:",
"telemetry": "Телеметрия",
"voltage": "Напряжение",
"battery": "Батарея",
@@ -165,8 +154,6 @@
"humidity": "Влажность",
"pressure": "Давление",
"gpsTelemetry": "GPS (телеметрия)",
"updated": "Обновлено",
"pathResetInfo": "Маршрут сброшен для {name}. Следующее сообщение найдёт новый путь.",
"reLoginToRoom": "Войти в комнату повторно",
"heading": "Курс",
"elevation": "Высота",
@@ -241,7 +228,6 @@
"tapToViewOnMap": "Нажмите, чтобы открыть на карте",
"radioSettings": "Настройки радио",
"frequencyMHz": "Частота (МГц)",
"frequencyExample": "например, 869.618",
"bandwidth": "Полоса пропускания",
"spreadingFactor": "Коэффициент расширения",
"codingRate": "Скорость кодирования",
@@ -264,38 +250,20 @@
"noTrailRecorded": "Трек ещё не записан",
"startTrackingToRecord": "Запустите отслеживание геолокации для записи трека",
"trailControls": "Управление треком",
"contactTrails": "Треки контактов",
"showAllContactTrails": "Показать все треки контактов",
"noContactsWithLocationHistory": "Нет контактов с историей местоположения",
"showingTrailsForContacts": "Показаны треки для {count} контактов",
"individualContactTrails": "Индивидуальные треки контактов",
"deviceInformation": "Информация об устройстве",
"bleName": "Имя BLE",
"meshName": "Имя в сети Mesh",
"notSet": "Не задано",
"model": "Модель",
"version": "Версия",
"buildDate": "Дата сборки",
"firmware": "Прошивка",
"maxContacts": "Макс. контактов",
"maxChannels": "Макс. каналов",
"publicInfo": "Публичная информация",
"meshNetworkName": "Название сети Mesh",
"nameBroadcastInMesh": "Имя, транслируемое в mesh-объявлениях",
"telemetryAndLocationSharing": "Телеметрия и передача местоположения",
"lat": "Ш",
"lon": "Д",
"useCurrentLocation": "Использовать текущее местоположение",
"noneUnknown": "Нет/Неизвестно",
"chatNode": "Узел чата",
"repeater": "Ретранслятор",
"roomChannel": "Комната/Канал",
"typeNumber": "Тип {number}",
"copiedToClipboardShort": "{label} скопировано в буфер обмена",
"failedToSave": "Не удалось сохранить: {error}",
"failedToGetLocation": "Не удалось получить местоположение: {error}",
"sarTemplates": "Шаблоны SAR",
"manageSarTemplates": "Управление шаблонами целеуказания",
"addTemplate": "Добавить шаблон",
"editTemplate": "Изменить шаблон",
"deleteTemplate": "Удалить шаблон",
@@ -323,7 +291,6 @@
"noTemplates": "Шаблоны недоступны",
"tapAddToCreate": "Нажмите +, чтобы создать первый шаблон",
"ok": "ОК",
"permissionsSection": "Разрешения",
"locationPermission": "Разрешение геолокации",
"checking": "Проверка...",
"locationPermissionGrantedAlways": "Разрешено (всегда)",
@@ -460,28 +427,6 @@
"wizardNext": "Далее",
"wizardGetStarted": "Начать",
"wizardWelcomeTitle": "Добро пожаловать в MeshCore SAR",
"wizardWelcomeDescription": "Мощный инструмент связи вне сети для поисково-спасательных операций. Поддерживайте связь с командой через mesh-радио, когда традиционные сети недоступны.",
"wizardConnectingTitle": "Подключение к радиоустройству",
"wizardConnectingDescription": "Подключите смартфон к радиоустройству MeshCore по Bluetooth для связи вне сети.",
"wizardConnectingFeature1": "Поиск ближайших устройств MeshCore",
"wizardConnectingFeature2": "Сопряжение с радиоустройством по Bluetooth",
"wizardConnectingFeature3": "Работает полностью офлайн — интернет не нужен",
"wizardChannelTitle": "Каналы",
"wizardChannelDescription": "Транслируйте сообщения всем участникам канала — идеально для общих объявлений и координации команды.",
"wizardChannelFeature1": "Публичный канал для общей связи команды",
"wizardChannelFeature2": "Создавайте пользовательские каналы для отдельных групп",
"wizardChannelFeature3": "Сообщения автоматически ретранслируются через mesh",
"wizardContactsTitle": "Контакты",
"wizardContactsDescription": "Члены вашей команды появляются автоматически по мере подключения к mesh-сети. Отправляйте им личные сообщения или просматривайте их местоположение.",
"wizardContactsFeature1": "Контакты обнаруживаются автоматически",
"wizardContactsFeature2": "Отправка личных сообщений",
"wizardContactsFeature3": "Просмотр уровня заряда и времени последнего появления",
"wizardMapTitle": "Карта и местоположение",
"wizardMapDescription": "Отслеживайте команду в реальном времени и отмечайте важные места для поисково-спасательных операций.",
"wizardMapFeature1": "Маркеры SAR для найденных людей, пожаров и мест сбора",
"wizardMapFeature2": "GPS-отслеживание членов команды в реальном времени",
"wizardMapFeature3": "Загрузка офлайн-карт для отдалённых районов",
"wizardMapFeature4": "Рисование фигур и обмен тактической информацией",
"viewWelcomeTutorial": "Просмотреть обучение",
"allTeamContacts": "Все контакты команды",
"directMessagesInfo": "Личные сообщения с подтверждением. Отправлено {count} членам команды.",
@@ -587,29 +532,10 @@
"posttxDelay": "Задержка после передачи",
"bandpass": "Полосовой",
"bandpassFilterVoice": "Полосовой фильтр голоса",
"frequency": "Частота",
"australia": "Австралия",
"australiaNarrow": "Австралия (Узкий)",
"australiaQld": "Австралия: QLD",
"australiaSaWa": "Австралия: SA, WA",
"newZealand": "Новая Зеландия",
"newZealandNarrow": "Новая Зеландия (Узкий)",
"switzerland": "Швейцария",
"portugal433": "Португалия 433",
"portugal868": "Португалия 868",
"czechRepublicNarrow": "Чехия (Узкий)",
"eu433mhzLongRange": "ЕС 433МГц (Дальний)",
"euukDeprecated": "ЕС/Великобритания (Устарело)",
"euukNarrow": "ЕС/Великобритания (Узкий)",
"usacanadaRecommended": "США/Канада (Рекомендуется)",
"vietnamDeprecated": "Вьетнам (Устарело)",
"vietnamNarrow": "Вьетнам (Узкий)",
"active": "Активно",
"addContact": "Добавить контакт",
"all": "Все",
"autoResolve": "Автоматическое разрешение",
"clearAllLabel": "Очистить все",
"clearRelays": "Очистить реле",
"clearFilters": "Очистить фильтры",
"clearRoute": "Очистить маршрут",
"clearMessages": "Очистить сообщения",
@@ -635,10 +561,7 @@
"send": "Отправить",
"sendAnyway": "Отправить все равно",
"share": "Поделиться",
"shareContact": "Поделиться контактом",
"trace": "Трассировка",
"use": "Использовать",
"useSelectedFrequency": "Использовать выбранную частоту",
"discovery": "Обнаружение",
"discoverRepeaters": "Обнаружить повторители",
"discoverSensors": "Обнаружить датчики",
@@ -661,7 +584,6 @@
"gpsModule": "Модуль GPS",
"liveTraffic": "Живой трафик",
"repeatersMap": "Карта повторителей",
"spectrumScan": "Сканирование спектра",
"blePacketLogs": "Журналы BLE-пакетов",
"onlineTraceDatabase": "База трассировок",
"routePathByteSize": "Размер пути в байтах",
@@ -673,7 +595,6 @@
"disableContacts": "Отключить контакты",
"enableSensorsTab": "Включить вкладку Датчики",
"enableProfiles": "Включить профили",
"autoRouteRotation": "Автоматическая ротация маршрута",
"nearestRepeaterFallback": "Ближайший повторитель как резерв",
"deleteAllStoredMessageHistory": "Удалить всю историю сообщений",
"messageFontSize": "Размер шрифта сообщений",
@@ -681,18 +602,13 @@
"showMapDebugInfo": "Показать отладочную информацию карты",
"openMapInFullscreen": "Открыть карту на весь экран",
"showSarMarkersLabel": "Показать SAR-маркеры",
"displaySarMarkersOnTheMainMap": "Отображать SAR-маркеры на основной карте",
"showAllContactTrailsLabel": "Показать все следы контактов",
"hideRepeatersOnMap": "Скрыть повторители на карте",
"setMapScale": "Установить масштаб карты",
"customMapScaleSaved": "Пользовательский масштаб карты сохранён",
"voiceBitrate": "Битрейт голоса",
"voiceCompressor": "Компрессор голоса",
"balancesQuietAndLoudSpeechLevels": "Выравнивает тихую и громкую речь",
"voiceLimiter": "Лимитер голоса",
"preventsClippingPeaksBeforeEncoding": "Предотвращает обрезку пиков перед кодированием",
"micAutoGain": "Автоусиление микрофона",
"letsTheRecorderAdjustInputLevel": "Позволяет рекордеру регулировать уровень входа",
"echoCancellation": "Подавление эха",
"noiseSuppression": "Подавление шума",
"trimSilenceInVoiceMessages": "Обрезать тишину в голосовых сообщениях",
@@ -734,13 +650,10 @@
"staging": "Место сбора",
"object": "Объект",
"quiet": "Тихо",
"moderate": "Умеренно",
"busy": "Занято",
"spectrumScanReturnedNoCandidateFrequencies": "Сканирование спектра не нашло частот-кандидатов",
"searchMessages": "Поиск сообщений",
"sendImageFromGallery": "Отправить изображение из галереи",
"takePhoto": "Сделать фото",
"dmOnly": "Только личные сообщения",
"allMessages": "Все сообщения",
"sendToPublicChannel": "Отправить в публичный канал?",
"selectMarkerTypeAndDestination": "Выберите тип маркера и назначение",
@@ -759,8 +672,6 @@
"wipeDevice": "Стереть устройство",
"destructiveDeviceActions": "Деструктивные действия устройства.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Выберите пресет или настройте радио вручную.",
"chooseTheNameAndLocationThisDeviceShares": "Выберите имя и местоположение, которыми делится устройство.",
"availableSpaceOnThisDevice": "Доступное место на этом устройстве.",
"used": "Использовано",
"total": "Всего",
"renameValue": "Переименовать значение",
@@ -778,8 +689,6 @@
"onlineOnly": "Только онлайн",
"inBoth": "В обоих",
"source": "Источник",
"manualRouteEdit": "Ручное редактирование маршрута",
"observedMeshRoute": "Наблюдаемый mesh-маршрут",
"allMessagesCleared": "Все сообщения очищены",
"onlineTraceDatabaseCleared": "База трассировок очищена",
"packetLogsCleared": "Журналы пакетов очищены",
@@ -810,5 +719,194 @@
"captured": "Захвачено",
"size": "Размер",
"noCustomChannelsToClear": "Нет пользовательских каналов для очистки.",
"noDeviceContactsToClear": "Нет контактов устройства для очистки."
"noDeviceContactsToClear": "Нет контактов устройства для очистки.",
"autoSend": "Автоотправка",
"autoSendDescription": "Отправить текущим маршрутом.",
"sendDirect": "Прямая отправка",
"sendDirectDescription": "Отправить напрямую этому контакту.",
"sendFlood": "Отправка лавиной",
"sendFloodDescription": "Отправить через все ретрансляторы.",
"postConnectDiscoveryTitle": "Обнаружить ретрансляторы сейчас?",
"postConnectDiscoveryDescription": "Запустите обнаружение ретрансляторов сразу после подключения, чтобы увидеть ближайшие узлы MeshCore и быстрее добавить их в свою сеть.",
"aToZ": "А-Я",
"add": "Добавить",
"addAdditionalDetails": "Добавить дополнительные сведения",
"addPeer": "Добавить узел",
"addPeerManually": "Добавить узел вручную",
"addedToContacts": "{name} добавлен в контакты",
"addedToSensors": "{name} добавлен в Датчики",
"allowAll": "Разрешить все",
"anonymousRxStats": "Анонимная статистика RX",
"appearance": "Внешний вид",
"ascii": "ASCII",
"baseTelemetry": "Базовая телеметрия",
"blePin": "BLE PIN",
"buzzerAlerts": "Звуковые оповещения",
"cancelImageReceive": "Отменить получение изображения",
"choose": "Выбрать",
"chooseWhichTabsAndContactSectionsToShow": "Выберите отображаемые вкладки и разделы контактов",
"clearCache": "Очистить кэш",
"clearHistory": "Очистить историю",
"clearLiveView": "Очистить режим реального времени",
"clearLogs": "Очистить журналы",
"clearOfflineCache": "Очистить офлайн-кэш?",
"clearOverlay": "Очистить наложение",
"clearRouteOnlyAfterAllRetriesFail": "Очищать маршрут только после всех неудачных повторов",
"codecBitrateAndAudioProcessing": "Кодек, битрейт и обработка звука",
"connectByIpAddress": "Подключиться по IP-адресу",
"controlWhichAlertsYouReceive": "Управление получаемыми оповещениями",
"copyFullHex": "Копировать полный Hex",
"copyRawResponse": "Копировать необработанный ответ",
"couldNotLoadMetHistory": "Не удалось загрузить историю MET",
"customizeSensor": "Настроить {name}",
"data": "Данные",
"deleteGroup": "Удалить группу",
"deleteStyleConfirm": "Удалить {name}?",
"deleteThisDrawing": "Удалить этот {type}?",
"deny": "Запретить",
"deviceInfo": "Информация об устройстве",
"deviceSettings": "Настройки устройства",
"deviceTimeSynced": "Время устройства синхронизировано с телефоном.",
"disableFastGpsPublishing": "Отключить быструю публикацию GPS",
"displayMarkersAndTraceDatabase": "Отображение, маркеры и база трассировок",
"download": "Скачать",
"environmentalTelemetry": "Телеметрия окружающей среды",
"errorCheckingForUpdates": "Ошибка проверки обновлений: {error}",
"errorGeneric": "Ошибка: {error}",
"errorOpeningDownload": "Ошибка открытия загрузки: {error}",
"exportLogs": "Экспортировать журналы",
"extraAggressiveAvifCompression": "Сверхагрессивное сжатие AVIF",
"failedToAddContact": "Не удалось добавить контакт: {error}",
"failedToAddName": "Не удалось добавить {name}: {error}",
"failedToClearChannels": "Не удалось очистить каналы: {error}",
"failedToClearContacts": "Не удалось очистить контакты: {error}",
"failedToConnectViaSerial": "Не удалось подключиться через последовательный порт",
"failedToLoadPreviewImage": "Не удалось загрузить изображение предпросмотра: {error}",
"failedToLoadTrace": "Не удалось загрузить трассировку: {error}",
"failedToSendSarMarker": "Не удалось отправить маркер SAR: {error}",
"failedToSetBuzzerMode": "Не удалось установить режим зуммера: {error}",
"failedToSetGpsMode": "Не удалось установить режим GPS: {error}",
"failedToSyncDeviceTime": "Не удалось синхронизировать время устройства: {error}",
"failedToWipeDeviceData": "Не удалось стереть данные устройства: {error}",
"fastGpsTargetChannel": "Целевой канал быстрого GPS",
"fastUpdatesThresholdsAndPermissions": "Быстрые обновления, пороги и разрешения",
"fetchingCatalog": "Загрузка каталога...",
"fifteenMinutes": "15 минут",
"fifteenSeconds": "15 секунд",
"filterByDirection": "Фильтр по направлению",
"finish": "Завершить",
"fiveMinutes": "5 минут",
"fiveSeconds": "5 секунд",
"fix": "Фикс",
"gps": "GPS",
"gpsAdvertPolicy": "Политика объявлений GPS",
"gpsAndLocation": "GPS и местоположение",
"gpsDiagnostics": "Диагностика GPS",
"gpsSection": "GPS",
"help": "Помощь",
"hex": "Hex",
"hidden": "Скрыто",
"howTheRadioAutoAddsDiscoveredNodes": "Как радио автоматически добавляет обнаруженные узлы.",
"images": "Изображения",
"importProfile": "Импортировать профиль",
"lastFix": "Последний фикс",
"latest": "Последняя",
"latitude": "Широта",
"loadASavedRegion": "Загрузить сохранённый регион",
"loading": "Загрузка",
"locationSharingHardwareAndUpdateInterval": "Обмен местоположением, оборудование и интервал обновления.",
"locationTelemetry": "Телеметрия местоположения",
"lockDestination": "Зафиксировать назначение",
"longitude": "Долгота",
"mapStyle": "Стиль карты",
"max": "Макс",
"maxZoom": "Макс. масштаб",
"messaging": "Сообщения",
"metHistory": "История MET",
"min": "Мин",
"minZoom": "Мин. масштаб",
"moveDown": "Переместить вниз",
"moveUp": "Переместить вверх",
"multiAckMode": "Режим множественного ACK",
"multiDeviceWorkspaceManagement": "Управление рабочим пространством на нескольких устройствах",
"myLocation": "Моё местоположение",
"nameAndTelemetryShared": "Имя и телеметрия доступны другим устройствам.",
"newVersionAvailable": "Новая версия: {version}",
"newer": "Новее",
"noNeighboursFound": "Соседи не найдены",
"noServersFound": "Серверы не найдены",
"notSet": "Не задано",
"notifications": "Уведомления",
"offZeroSeconds": "Выкл (0 с)",
"offlineMaps": "Офлайн-карты",
"older": "Старее",
"oneByteMode0": "1 байт (режим 0)",
"oneHour": "1 час",
"oneMinute": "1 минута",
"openPacketLogs": "Открыть журналы пакетов",
"packetTypeHelp": "Справка по типам пакетов",
"pasteShareLinkOrHexAdvert": "Вставьте ссылку или Hex-объявление",
"pathHash": "Хеш пути",
"pathSize": "Размер пути",
"ping": "Пинг",
"polygon": "Полигон",
"rawResponseCopied": "Необработанный ответ скопирован",
"resendThroughNearestRepeaterOnFailure": "Повторить через ближайший ретранслятор при ошибке",
"resolveContact": "Разрешить контакт",
"routingRetriesAndDestinationLock": "Маршрутизация, повторы и фиксация назначения",
"samples": "Образцы",
"satellites": "Спутники",
"saving": "Сохранение...",
"search": "Поиск",
"searchByNameOrToken": "Поиск по имени или токену",
"searchDiscoveredNodes": "Поиск обнаруженных узлов",
"searchInCurrentFilter": "Поиск в текущем фильтре",
"searchLogs": "Поиск по журналам...",
"searchSensors": "Поиск датчиков",
"searchThisSection": "Поиск в этом разделе",
"selectedRelays": "Выбранные ретрансляторы",
"sensor": "Датчик",
"serialError": "Ошибка последовательного порта: {error}",
"setPathForContact": "Задать путь для {name}",
"shareLiveGps": "Транслировать GPS",
"shareMyTiles": "Поделиться моими тайлами",
"sharingLocationFromPhone": "Трансляция местоположения на этом канале с телефона.",
"sharingLocationFromRadio": "Трансляция местоположения на этом канале с радио.",
"signalDbm": "Сигнал {rssi} дБм",
"sizeCompressionAndPreview": "Размер, сжатие и предпросмотр",
"skip": "Пропустить",
"smallerFileSize": "Меньший размер файла",
"sort": "Сортировка",
"stoppedSharingLocation": "Трансляция местоположения на этом канале остановлена.",
"suppressNotificationsWhileInForeground": "Отключить уведомления при открытом приложении",
"tabsAndNavigation": "Вкладки и навигация",
"templatesAndTutorials": "Шаблоны и обучение",
"tenMinutes": "10 минут",
"tenSeconds": "10 секунд",
"testSendUpdate": "Тестовая отправка обновления",
"themeLanguageAndDisplayPreferences": "Тема, язык и настройки отображения",
"thirtyMinutes": "30 минут",
"thirtySeconds": "30 секунд",
"threeBytesMode2": "3 байта (режим 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Статистика трафика, история сообщений и инструменты разработчика",
"twoBytesMode1": "2 байта (режим 1)",
"twoMinutes": "2 минуты",
"undo": "Отменить",
"updated": "Обновлено",
"useContactFlags": "Использовать флаги контактов",
"useSavedCoordinates": "Использовать сохранённые координаты",
"view": "Просмотр",
"viewNeighbours": "Просмотр соседей",
"viewPublicStats": "Просмотр публичной статистики",
"setRegionScope": "Установить область региона",
"regionScope": "Область региона",
"regionScopeNone": "Нет (глобально)",
"clearRegionScope": "Очистить область",
"regionScopeWarning": "Только ретрансляторы, разрешающие этот регион, будут пересылать.",
"discoverRegions": "Обнаружить из ретрансляторов",
"discoveringRegions": "Обнаружение регионов...",
"enterRegionName": "Название региона (напр. auckland)",
"noRegionsFound": "Регионы не найдены на этом ретрансляторе.",
"regionScopeSet": "Область региона установлена на {name}",
"regionScopeCleared": "Область региона очищена"
}

View File

@@ -9,7 +9,6 @@
"disconnect": "Prekini",
"noDevicesFound": "Ni najdenih naprav",
"scanAgain": "Ponovi iskanje",
"tapToConnect": "Tapnite za povezavo",
"deviceNotConnected": "Naprava ni povezana",
"locationPermissionDenied": "Dovoljenje za lokacijo zavrnjeno",
"locationPermissionPermanentlyDenied": "Dovoljenje za lokacijo trajno zavrnjeno. Prosimo, omogočite v Nastavitvah.",
@@ -32,9 +31,7 @@
"autoSystem": "Samodejno (Sistem)",
"followSystemTheme": "Sledi sistemski temi",
"showRxTxIndicators": "Prikaži RX/TX kazalnike",
"displayPacketActivity": "Prikaži kazalnike aktivnosti paketov v zgornji vrstici",
"disableMap": "Onemogoči zemljevid",
"disableMapDescription": "Skrij zavihek z zemljevidom za varčevanje z baterijo",
"language": "Jezik",
"chooseLanguage": "Izberite jezik",
"save": "Shrani",
@@ -42,7 +39,6 @@
"close": "Zapri",
"about": "O aplikaciji",
"appVersion": "Različica aplikacije",
"appName": "Ime aplikacije",
"aboutMeshCoreSar": "O MeshCore SAR",
"aboutDescription": "Aplikacija za iskanje in reševanje, zasnovana za ekipe za odzivanje v nujnih primerih. Funkcije vključujejo:\n\n• BLE mesh omrežje za komunikacijo naprava-naprava\n• Brez povezave delujejo zemljevidi z več sloji\n• Sledenje članov ekipe v realnem času\n• SAR taktični označevalci (najdena oseba, ogenj, zbirališče)\n• Upravljanje stikov in sporočanje\n• GPS sledenje s kompasno smerjo\n• Predpomnenje ploščic zemljevida za uporabo brez povezave",
"technologiesUsed": "Uporabljene tehnologije:",
@@ -69,7 +65,6 @@
"viewOnMap": "Poglej na zemljevidu",
"refresh": "Osveži",
"resetPath": "Ponastavi pot (preusmeri)",
"publicKeyCopied": "Javni ključ kopiran v odložišče",
"copiedToClipboard": "{label} kopirano v odložišče",
"pleaseEnterPassword": "Prosimo, vnesite geslo",
"failedToSyncContacts": "Sinhronizacija stikov ni uspela: {error}",
@@ -148,15 +143,9 @@
"type": "Vrsta",
"publicKey": "Javni ključ",
"lastSeen": "Nazadnje viden",
"roomStatus": "Status sobe",
"loginStatus": "Status prijave",
"notLoggedIn": "Ni prijavljen",
"adminAccess": "Administratorski dostop",
"yes": "Da",
"no": "Ne",
"permissions": "Dovoljenja",
"passwordSaved": "Geslo shranjeno",
"locationColon": "Lokacija:",
"telemetry": "Telemetrija",
"voltage": "Napetost",
"battery": "Baterija",
@@ -164,8 +153,6 @@
"humidity": "Vlažnost",
"pressure": "Tlak",
"gpsTelemetry": "GPS (Telemetrija)",
"updated": "Posodobljeno",
"pathResetInfo": "Pot ponastavljena za {name}. Naslednje sporočilo bo našlo novo pot.",
"reLoginToRoom": "Ponovna prijava v sobo",
"heading": "Smer",
"elevation": "Nadmorska višina",
@@ -241,7 +228,6 @@
"tapToViewOnMap": "Tapnite za prikaz na zemljevidu",
"radioSettings": "Nastavitve radia",
"frequencyMHz": "Frekvenca (MHz)",
"frequencyExample": "npr. 869.618",
"bandwidth": "Pasovna širina",
"spreadingFactor": "Faktor razširitve",
"codingRate": "Razmerje kodiranja",
@@ -264,38 +250,20 @@
"noTrailRecorded": "Še ni posnete poti",
"startTrackingToRecord": "Začnite sledenje lokacije za snemanje poti",
"trailControls": "Nadzor poti",
"contactTrails": "Poti stikov",
"showAllContactTrails": "Prikaži vse poti stikov",
"noContactsWithLocationHistory": "Ni stikov z zgodovino lokacije",
"showingTrailsForContacts": "Prikazujem poti za {count} stikov",
"individualContactTrails": "Posamezne poti stikov",
"deviceInformation": "Informacije o napravi",
"bleName": "BLE ime",
"meshName": "Mesh ime",
"notSet": "Ni nastavljeno",
"model": "Model",
"version": "Različica",
"buildDate": "Datum izdelave",
"firmware": "Vdelana programska oprema",
"maxContacts": "Maks. stikov",
"maxChannels": "Maks. kanalov",
"publicInfo": "Javne informacije",
"meshNetworkName": "Ime mesh omrežja",
"nameBroadcastInMesh": "Ime, ki se oddaja v mesh oglasih",
"telemetryAndLocationSharing": "Telemetrija in deljenje lokacije",
"lat": "Šir",
"lon": "Dolž",
"useCurrentLocation": "Uporabi trenutno lokacijo",
"noneUnknown": "Brez/Neznano",
"chatNode": "Vozlišče za klepet",
"repeater": "Ponavljalnik",
"roomChannel": "Soba/Kanal",
"typeNumber": "Tip {number}",
"copiedToClipboardShort": "Kopirano {label} v odložišče",
"failedToSave": "Shranjevanje ni uspelo: {error}",
"failedToGetLocation": "Pridobivanje lokacije ni uspelo: {error}",
"sarTemplates": "SAR predloge",
"manageSarTemplates": "Upravljanje SAR predlog",
"addTemplate": "Dodaj predlogo",
"editTemplate": "Uredi predlogo",
"deleteTemplate": "Izbriši predlogo",
@@ -323,7 +291,6 @@
"noTemplates": "Ni razpoložljivih predlog",
"tapAddToCreate": "Tapnite + za ustvarjanje prve predloge",
"ok": "V redu",
"permissionsSection": "Dovoljenja",
"locationPermission": "Dovoljenje za lokacijo",
"checking": "Preverjanje...",
"locationPermissionGrantedAlways": "Odobreno (Vedno)",
@@ -460,28 +427,6 @@
"wizardNext": "Naprej",
"wizardGetStarted": "Začni",
"wizardWelcomeTitle": "Dobrodošli v MeshCore SAR",
"wizardWelcomeDescription": "Zmogljivo orodje za komunikacijo brez omrežja za reševalne operacije. S svojo ekipo se povežite z mesh radijsko tehnologijo, ko tradicionalna omrežja niso na voljo.",
"wizardConnectingTitle": "Povezava z radijem",
"wizardConnectingDescription": "Svoj telefon povežite z radijsko napravo MeshCore prek Bluetootha in začnite komunicirati brez omrežja.",
"wizardConnectingFeature1": "Poišče bližnje naprave MeshCore",
"wizardConnectingFeature2": "Seznanitev z radijsko napravo prek Bluetootha",
"wizardConnectingFeature3": "Deluje povsem brez povezave — internet ni potreben",
"wizardChannelTitle": "Kanali",
"wizardChannelDescription": "Pošiljajte sporočila vsem na kanalu — idealno za obvestila in koordinacijo ekipe.",
"wizardChannelFeature1": "Javni kanal za splošno komunikacijo ekipe",
"wizardChannelFeature2": "Ustvarite kanale po meri za določene skupine",
"wizardChannelFeature3": "Sporočila se samodejno posredujejo prek mreže",
"wizardContactsTitle": "Stiki",
"wizardContactsDescription": "Člani ekipe se prikažejo samodejno, ko se pridružijo mesh omrežju. Pošiljajte jim neposredna sporočila ali si oglejte njihovo lokacijo.",
"wizardContactsFeature1": "Samodejno odkrivanje stikov",
"wizardContactsFeature2": "Pošiljanje zasebnih neposrednih sporočil",
"wizardContactsFeature3": "Prikaz stanja baterije in časa zadnje aktivnosti",
"wizardMapTitle": "Zemljevid in lokacija",
"wizardMapDescription": "Spremljajte svojo ekipo v realnem času in označujte ključne lokacije za reševalne operacije.",
"wizardMapFeature1": "SAR označevalci za najdene osebe, požare in zbirna mesta",
"wizardMapFeature2": "Sledenje članom ekipe z GPS v realnem času",
"wizardMapFeature3": "Prenesite zemljevide za uporabo brez povezave",
"wizardMapFeature4": "Rišite oblike in delite taktične informacije",
"viewWelcomeTutorial": "Ogled vadnice dobrodošlice",
"allTeamContacts": "Vsi stiki ekipe",
"directMessagesInfo": "Neposredna sporočila s potrditvami. Poslano {count} članom ekipe.",
@@ -587,29 +532,10 @@
"posttxDelay": "Zakasnitev po oddaji",
"bandpass": "Pasovno sito",
"bandpassFilterVoice": "Pasovno sito za glas",
"frequency": "Frekvenca",
"australia": "Avstralija",
"australiaNarrow": "Avstralija (ozko)",
"australiaQld": "Avstralija: QLD",
"australiaSaWa": "Avstralija: SA, WA",
"newZealand": "Nova Zelandija",
"newZealandNarrow": "Nova Zelandija (ozko)",
"switzerland": "Švica",
"portugal433": "Portugalska 433",
"portugal868": "Portugalska 868",
"czechRepublicNarrow": "Češka (ozko)",
"eu433mhzLongRange": "EU 433MHz (dolg doseg)",
"euukDeprecated": "EU/UK (zastarelo)",
"euukNarrow": "EU/UK (ozko)",
"usacanadaRecommended": "ZDA/Kanada (priporočeno)",
"vietnamDeprecated": "Vietnam (zastarelo)",
"vietnamNarrow": "Vietnam (ozko)",
"active": "Aktivno",
"addContact": "Dodaj stik",
"all": "Vse",
"autoResolve": "Samodejno razreši",
"clearAllLabel": "Počisti vse",
"clearRelays": "Počisti posrednike",
"clearFilters": "Počisti filtre",
"clearRoute": "Počisti pot",
"clearMessages": "Počisti sporočila",
@@ -635,10 +561,7 @@
"send": "Pošlji",
"sendAnyway": "Vseeno pošlji",
"share": "Deli",
"shareContact": "Deli stik",
"trace": "Sledenje",
"use": "Uporabi",
"useSelectedFrequency": "Uporabi izbrano frekvenco",
"discovery": "Odkrivanje",
"discoverRepeaters": "Odkrij posrednike",
"discoverSensors": "Odkrij senzorje",
@@ -661,7 +584,6 @@
"gpsModule": "GPS modul",
"liveTraffic": "Promet v živo",
"repeatersMap": "Zemljevid posrednikov",
"spectrumScan": "Spektralno skeniranje",
"blePacketLogs": "Dnevniki BLE paketov",
"onlineTraceDatabase": "Baza sledi",
"routePathByteSize": "Velikost poti v bajtih",
@@ -673,7 +595,6 @@
"disableContacts": "Onemogoči stike",
"enableSensorsTab": "Omogoči zavihek Senzorji",
"enableProfiles": "Omogoči profile",
"autoRouteRotation": "Samodejno kroženje poti",
"nearestRepeaterFallback": "Najbližji posrednik kot rezerva",
"deleteAllStoredMessageHistory": "Izbriši vso shranjeno zgodovino sporočil",
"messageFontSize": "Velikost pisave sporočil",
@@ -681,18 +602,13 @@
"showMapDebugInfo": "Prikaži razhroščevalne info zemljevida",
"openMapInFullscreen": "Odpri zemljevid na cel zaslon",
"showSarMarkersLabel": "Prikaži SAR oznake",
"displaySarMarkersOnTheMainMap": "Prikaži SAR oznake na glavnem zemljevidu",
"showAllContactTrailsLabel": "Prikaži vse sledi stikov",
"hideRepeatersOnMap": "Skrij posrednike na zemljevidu",
"setMapScale": "Nastavi merilo zemljevida",
"customMapScaleSaved": "Merilo zemljevida shranjeno",
"voiceBitrate": "Bitna hitrost glasu",
"voiceCompressor": "Kompresor glasu",
"balancesQuietAndLoudSpeechLevels": "Izravnava tihe in glasne govorne ravni",
"voiceLimiter": "Omejevalnik glasu",
"preventsClippingPeaksBeforeEncoding": "Preprečuje rezanje vrhov pred kodiranjem",
"micAutoGain": "Samodejna ojačitev mikrofona",
"letsTheRecorderAdjustInputLevel": "Omogoči snemalniku prilagoditev vhodne ravni",
"echoCancellation": "Odstranjevanje odmeva",
"noiseSuppression": "Dušenje šuma",
"trimSilenceInVoiceMessages": "Obreži tišino v glasovnih sporočilih",
@@ -734,13 +650,10 @@
"staging": "Zbirno mesto",
"object": "Predmet",
"quiet": "Tiho",
"moderate": "Zmerno",
"busy": "Zasedeno",
"spectrumScanReturnedNoCandidateFrequencies": "Spektralno skeniranje ni našlo primernih frekvenc",
"searchMessages": "Iskanje sporočil",
"sendImageFromGallery": "Pošlji sliko iz galerije",
"takePhoto": "Posnemi fotografijo",
"dmOnly": "Samo neposredno",
"allMessages": "Vsa sporočila",
"sendToPublicChannel": "Pošlji na javni kanal?",
"selectMarkerTypeAndDestination": "Izberite vrsto oznake in cilj",
@@ -759,8 +672,6 @@
"wipeDevice": "Izbriši napravo",
"destructiveDeviceActions": "Destruktivna dejanja na napravi.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Izberite prednastavitev ali natančno prilagodite radijske nastavitve.",
"chooseTheNameAndLocationThisDeviceShares": "Izberite ime in lokacijo, ki ju naprava deli.",
"availableSpaceOnThisDevice": "Razpoložljiv prostor na tej napravi.",
"used": "Uporabljeno",
"total": "Skupaj",
"renameValue": "Preimenuj vrednost",
@@ -778,8 +689,6 @@
"onlineOnly": "Samo povezani",
"inBoth": "V obeh",
"source": "Vir",
"manualRouteEdit": "Ročno urejanje poti",
"observedMeshRoute": "Opazovana mrežna pot",
"allMessagesCleared": "Vsa sporočila počiščena",
"onlineTraceDatabaseCleared": "Baza sledi počiščena",
"packetLogsCleared": "Dnevniki paketov počiščeni",
@@ -810,5 +719,194 @@
"captured": "Zajeto",
"size": "Velikost",
"noCustomChannelsToClear": "Ni prilagojenih kanalov za brisanje.",
"noDeviceContactsToClear": "Ni stikov naprave za brisanje."
"noDeviceContactsToClear": "Ni stikov naprave za brisanje.",
"autoSend": "Samodejno pošiljanje",
"autoSendDescription": "Pošlji po trenutni poti.",
"sendDirect": "Neposredno pošiljanje",
"sendDirectDescription": "Pošlji neposredno temu kontaktu.",
"sendFlood": "Pošiljanje s poplavo",
"sendFloodDescription": "Pošlji prek vseh repetitorjev.",
"postConnectDiscoveryTitle": "Odkriti repetitorje zdaj?",
"postConnectDiscoveryDescription": "Zaženite odkrivanje repetitorjev takoj po povezavi, da vidite bližnja vozlišča MeshCore in jih hitreje dodate v svoje omrežje.",
"aToZ": "A-Ž",
"add": "Dodaj",
"addAdditionalDetails": "Dodaj dodatne podrobnosti",
"addPeer": "Dodaj soležnika",
"addPeerManually": "Ročno dodaj soležnika",
"addedToContacts": "{name} dodan med stike",
"addedToSensors": "{name} dodan med senzorje",
"allowAll": "Dovoli vse",
"anonymousRxStats": "Anonimna RX statistika",
"appearance": "Videz",
"ascii": "ASCII",
"baseTelemetry": "Osnovna telemetrija",
"blePin": "BLE PIN",
"buzzerAlerts": "Zvočni alarmi",
"cancelImageReceive": "Prekliči prejem slike",
"choose": "Izberi",
"chooseWhichTabsAndContactSectionsToShow": "Izberite, katere zavihke in razdelke stikov prikazati",
"clearCache": "Počisti predpomnilnik",
"clearHistory": "Počisti zgodovino",
"clearLiveView": "Počisti pogled v živo",
"clearLogs": "Počisti dnevnike",
"clearOfflineCache": "Počisti predpomnilnik brez povezave?",
"clearOverlay": "Počisti prekrivanje",
"clearRouteOnlyAfterAllRetriesFail": "Počisti pot šele po neuspehu vseh ponovitev",
"codecBitrateAndAudioProcessing": "Kodek, bitna hitrost in obdelava zvoka",
"connectByIpAddress": "Poveži prek IP naslova",
"controlWhichAlertsYouReceive": "Nadzirajte, katera opozorila prejemate",
"copyFullHex": "Kopiraj celoten Hex",
"copyRawResponse": "Kopiraj surov odgovor",
"couldNotLoadMetHistory": "Nalaganje MET zgodovine ni uspelo",
"customizeSensor": "Prilagodi {name}",
"data": "Podatki",
"deleteGroup": "Izbriši skupino",
"deleteStyleConfirm": "Izbriši {name}?",
"deleteThisDrawing": "Izbriši to {type}?",
"deny": "Zavrni",
"deviceInfo": "Informacije o napravi",
"deviceSettings": "Nastavitve naprave",
"deviceTimeSynced": "Čas naprave sinhroniziran s tem telefonom.",
"disableFastGpsPublishing": "Onemogoči hitro GPS objavljanje",
"displayMarkersAndTraceDatabase": "Prikaz, oznake in baza sledenja",
"download": "Prenesi",
"environmentalTelemetry": "Okoljska telemetrija",
"errorCheckingForUpdates": "Napaka pri preverjanju posodobitev: {error}",
"errorGeneric": "Napaka: {error}",
"errorOpeningDownload": "Napaka pri odpiranju prenosa: {error}",
"exportLogs": "Izvozi dnevnike",
"extraAggressiveAvifCompression": "Posebno agresivno AVIF stiskanje",
"failedToAddContact": "Dodajanje stika ni uspelo: {error}",
"failedToAddName": "Dodajanje {name} ni uspelo: {error}",
"failedToClearChannels": "Brisanje kanalov ni uspelo: {error}",
"failedToClearContacts": "Brisanje stikov ni uspelo: {error}",
"failedToConnectViaSerial": "Povezava prek serijske povezave ni uspela",
"failedToLoadPreviewImage": "Nalaganje predogleda slike ni uspelo: {error}",
"failedToLoadTrace": "Nalaganje sledi ni uspelo: {error}",
"failedToSendSarMarker": "Pošiljanje SAR oznake ni uspelo: {error}",
"failedToSetBuzzerMode": "Nastavitev zvočnega alarma ni uspela: {error}",
"failedToSetGpsMode": "Nastavitev GPS načina ni uspela: {error}",
"failedToSyncDeviceTime": "Sinhronizacija časa naprave ni uspela: {error}",
"failedToWipeDeviceData": "Brisanje podatkov naprave ni uspelo: {error}",
"fastGpsTargetChannel": "Ciljni kanal hitrega GPS-a",
"fastUpdatesThresholdsAndPermissions": "Hitre posodobitve, pragovi in dovoljenja",
"fetchingCatalog": "Pridobivanje kataloga...",
"fifteenMinutes": "15 minut",
"fifteenSeconds": "15 sekund",
"filterByDirection": "Filtriraj po smeri",
"finish": "Končaj",
"fiveMinutes": "5 minut",
"fiveSeconds": "5 sekund",
"fix": "Popravi",
"gps": "GPS",
"gpsAdvertPolicy": "Politika GPS oglašanja",
"gpsAndLocation": "GPS in lokacija",
"gpsDiagnostics": "GPS diagnostika",
"gpsSection": "GPS",
"help": "Pomoč",
"hex": "Hex",
"hidden": "Skrito",
"howTheRadioAutoAddsDiscoveredNodes": "Kako radio samodejno dodaja odkrita vozlišča.",
"images": "Slike",
"importProfile": "Uvozi profil",
"lastFix": "Zadnja posneta lokacija",
"latest": "Najnovejše",
"latitude": "Geografska širina",
"loadASavedRegion": "Naloži shranjeno regijo",
"loading": "Nalaganje",
"locationSharingHardwareAndUpdateInterval": "Deljenje lokacije, strojna oprema in interval posodobitev.",
"locationTelemetry": "Telemetrija lokacije",
"lockDestination": "Zakleni cilj",
"longitude": "Geografska dolžina",
"mapStyle": "Slog zemljevida",
"max": "Maks",
"maxZoom": "Maks. povečava",
"messaging": "Sporočanje",
"metHistory": "MET zgodovina",
"min": "Min",
"minZoom": "Min. povečava",
"moveDown": "Premakni navzdol",
"moveUp": "Premakni navzgor",
"multiAckMode": "Način večkratne ACK potrditve",
"multiDeviceWorkspaceManagement": "Upravljanje delovnega prostora z več napravami",
"myLocation": "Moja lokacija",
"nameAndTelemetryShared": "Ime in telemetrija deljena z drugimi napravami.",
"newVersionAvailable": "Nova različica: {version}",
"newer": "Novejše",
"noNeighboursFound": "Ni najdenih sosedov",
"noServersFound": "Ni najdenih strežnikov",
"notSet": "Ni nastavljeno",
"notifications": "Obvestila",
"offZeroSeconds": "Izključeno (0 s)",
"offlineMaps": "Zemljevidi brez povezave",
"older": "Starejše",
"oneByteMode0": "1 bajt (način 0)",
"oneHour": "1 ura",
"oneMinute": "1 minuta",
"openPacketLogs": "Odpri dnevnike paketov",
"packetTypeHelp": "Pomoč za vrsto paketa",
"pasteShareLinkOrHexAdvert": "Prilepite deljeno povezavo ali Hex oglas",
"pathHash": "Zgoščevalna vrednost poti",
"pathSize": "Velikost poti",
"ping": "Ping",
"polygon": "Poligon",
"rawResponseCopied": "Surov odgovor kopiran",
"resendThroughNearestRepeaterOnFailure": "Ponovno pošlji prek najbližjega posrednika ob neuspehu",
"resolveContact": "Razreši stik",
"routingRetriesAndDestinationLock": "Usmerjanje, ponovitve in zaklepanje cilja",
"samples": "Vzorci",
"satellites": "Sateliti",
"saving": "Shranjujem...",
"search": "Iskanje",
"searchByNameOrToken": "Iskanje po imenu ali žetonu",
"searchDiscoveredNodes": "Iskanje odkritih vozlišč",
"searchInCurrentFilter": "Iskanje v trenutnem filtru",
"searchLogs": "Iskanje dnevnikov...",
"searchSensors": "Iskanje senzorjev",
"searchThisSection": "Iskanje v tem razdelku",
"selectedRelays": "Izbrani posredniki",
"sensor": "Senzor",
"serialError": "Napaka serijske povezave: {error}",
"setPathForContact": "Nastavi pot za {name}",
"shareLiveGps": "Deli GPS v živo",
"shareMyTiles": "Deli moje ploščice",
"sharingLocationFromPhone": "Deljenje lokacije na tem kanalu s telefona.",
"sharingLocationFromRadio": "Deljenje lokacije na tem kanalu z radia.",
"signalDbm": "Signal {rssi} dBm",
"sizeCompressionAndPreview": "Velikost, stiskanje in predogled",
"skip": "Preskoči",
"smallerFileSize": "Manjša velikost datoteke",
"sort": "Razvrsti",
"stoppedSharingLocation": "Deljenje lokacije na tem kanalu ustavljeno.",
"suppressNotificationsWhileInForeground": "Zadrži obvestila, ko je aplikacija v ospredju",
"tabsAndNavigation": "Zavihki in navigacija",
"templatesAndTutorials": "Predloge in vadnice",
"tenMinutes": "10 minut",
"tenSeconds": "10 sekund",
"testSendUpdate": "Preizkusno pošlji posodobitev",
"themeLanguageAndDisplayPreferences": "Tema, jezik in nastavitve prikaza",
"thirtyMinutes": "30 minut",
"thirtySeconds": "30 sekund",
"threeBytesMode2": "3 bajti (način 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Statistika prometa, zgodovina sporočil in razvojna orodja",
"twoBytesMode1": "2 bajta (način 1)",
"twoMinutes": "2 minuti",
"undo": "Razveljavi",
"updated": "Posodobljeno",
"useContactFlags": "Uporabi zastavice stikov",
"useSavedCoordinates": "Uporabi shranjene koordinate",
"view": "Pogled",
"viewNeighbours": "Poglej sosede",
"viewPublicStats": "Poglej javno statistiko",
"setRegionScope": "Nastavi obseg regije",
"regionScope": "Obseg regije",
"regionScopeNone": "Brez (globalno)",
"clearRegionScope": "Počisti obseg",
"regionScopeWarning": "Samo repetitorji, ki dovoljujejo to regijo, bodo posredovali.",
"discoverRegions": "Odkrij iz repetitorjev",
"discoveringRegions": "Odkrivanje regij...",
"enterRegionName": "Ime regije (npr. auckland)",
"noRegionsFound": "Na tem repetitorju ni najdenih regij.",
"regionScopeSet": "Obseg regije nastavljen na {name}",
"regionScopeCleared": "Obseg regije počiščen"
}

View File

@@ -36,10 +36,6 @@
"@scanAgain": {
"description": "Button to restart BLE scanning"
},
"tapToConnect": "Bağlanmak için dokunun",
"@tapToConnect": {
"description": "Subtitle text for device in scan list"
},
"deviceNotConnected": "Cihaz bağlı değil",
"@deviceNotConnected": {
"description": "Error message when device is not connected"
@@ -133,18 +129,10 @@
"@showRxTxIndicators": {
"description": "Setting to show RX/TX indicators"
},
"displayPacketActivity": "Üst çubukta paket etkinliği göstergelerini göster",
"@displayPacketActivity": {
"description": "Description for RX/TX indicators setting"
},
"disableMap": "Haritayı devre dışı bırak",
"@disableMap": {
"description": "Setting to disable the map tab"
},
"disableMapDescription": "Pil kullanımını azaltmak için harita sekmesini gizle",
"@disableMapDescription": {
"description": "Description for disable map setting"
},
"language": "Dil",
"@language": {
"description": "Language setting label"
@@ -173,10 +161,6 @@
"@appVersion": {
"description": "App version label"
},
"appName": "Uygulama adı",
"@appName": {
"description": "App name label"
},
"aboutMeshCoreSar": "MeshCore SAR hakkında",
"@aboutMeshCoreSar": {
"description": "About dialog title"
@@ -308,10 +292,6 @@
"@resetPath": {
"description": "Action to reset contact path for re-routing"
},
"publicKeyCopied": "Genel anahtar panoya kopyalandı",
"@publicKeyCopied": {
"description": "Success message when public key is copied"
},
"copiedToClipboard": "{label} panoya kopyalandı",
"@copiedToClipboard": {
"description": "Success message when a value is copied to clipboard",
@@ -720,22 +700,6 @@
"@lastSeen": {
"description": "Last seen label"
},
"roomStatus": "Oda durumu",
"@roomStatus": {
"description": "Room status section header"
},
"loginStatus": "Giriş durumu",
"@loginStatus": {
"description": "Login status label"
},
"notLoggedIn": "Giriş yapılmadı",
"@notLoggedIn": {
"description": "Not logged in status"
},
"adminAccess": "Yönetici erişimi",
"@adminAccess": {
"description": "Admin access label"
},
"yes": "Evet",
"@yes": {
"description": "Yes answer"
@@ -748,14 +712,6 @@
"@permissions": {
"description": "Permissions label"
},
"passwordSaved": "Parola kaydedildi",
"@passwordSaved": {
"description": "Password saved label"
},
"locationColon": "Konum:",
"@locationColon": {
"description": "Location section header"
},
"telemetry": "Telemetri",
"@telemetry": {
"description": "Telemetry section header"
@@ -784,19 +740,6 @@
"@gpsTelemetry": {
"description": "GPS from telemetry label"
},
"updated": "Güncellendi",
"@updated": {
"description": "Updated timestamp label"
},
"pathResetInfo": "{name} için yol sıfırlandı. Sonraki mesaj yeni bir rota bulacak.",
"@pathResetInfo": {
"description": "Info message after path reset",
"placeholders": {
"name": {
"type": "String"
}
}
},
"reLoginToRoom": "Odaya yeniden giriş yap",
"@reLoginToRoom": {
"description": "Button to re-login to room"
@@ -1158,10 +1101,6 @@
"@frequencyMHz": {
"description": "Label for radio frequency field"
},
"frequencyExample": "örn. 869.618",
"@frequencyExample": {
"description": "Helper text example for frequency"
},
"bandwidth": "Bant genişliği",
"@bandwidth": {
"description": "Label for bandwidth dropdown"
@@ -1265,47 +1204,6 @@
"@trailControls": {
"description": "Trail controls tooltip"
},
"contactTrails": "Kişi izleri",
"@contactTrails": {
"description": "Contact trails section header"
},
"showAllContactTrails": "Tüm kişi izlerini göster",
"@showAllContactTrails": {
"description": "Toggle label to show all contact trails"
},
"noContactsWithLocationHistory": "Konum geçmişi olan kişi yok",
"@noContactsWithLocationHistory": {
"description": "Subtitle when no contacts have trails"
},
"showingTrailsForContacts": "{count} kişi için izler gösteriliyor",
"@showingTrailsForContacts": {
"description": "Subtitle showing number of contacts with trails",
"placeholders": {
"count": {
"type": "int"
}
}
},
"individualContactTrails": "Tek tek kişi izleri",
"@individualContactTrails": {
"description": "Expansion tile title for individual contact trails"
},
"deviceInformation": "Cihaz bilgileri",
"@deviceInformation": {
"description": "Device information section header"
},
"bleName": "BLE adı",
"@bleName": {
"description": "Bluetooth Low Energy device name label"
},
"meshName": "Mesh adı",
"@meshName": {
"description": "Mesh network name label"
},
"notSet": "Ayarlanmadı",
"@notSet": {
"description": "Label when a value is not set"
},
"model": "Model",
"@model": {
"description": "Device model label"
@@ -1314,14 +1212,6 @@
"@version": {
"description": "Version label"
},
"buildDate": "Derleme tarihi",
"@buildDate": {
"description": "Firmware build date label"
},
"firmware": "Bellenim",
"@firmware": {
"description": "Firmware label"
},
"maxContacts": "Maks kişi",
"@maxContacts": {
"description": "Maximum contacts capacity label"
@@ -1334,26 +1224,6 @@
"@publicInfo": {
"description": "Public information section header"
},
"meshNetworkName": "Mesh ağ adı",
"@meshNetworkName": {
"description": "Mesh network name field label"
},
"nameBroadcastInMesh": "Mesh duyurularında yayınlanan ad",
"@nameBroadcastInMesh": {
"description": "Helper text for mesh network name field"
},
"telemetryAndLocationSharing": "Telemetri ve konum paylaşımı",
"@telemetryAndLocationSharing": {
"description": "Telemetry and location sharing toggle label"
},
"lat": "Enl.",
"@lat": {
"description": "Latitude field label (short form)"
},
"lon": "Boyl.",
"@lon": {
"description": "Longitude field label (short form)"
},
"useCurrentLocation": "Geçerli konumu kullan",
"@useCurrentLocation": {
"description": "Tooltip for use current location button"
@@ -1383,15 +1253,6 @@
}
}
},
"copiedToClipboardShort": "{label} panoya kopyalandı",
"@copiedToClipboardShort": {
"description": "Short success message when copying to clipboard",
"placeholders": {
"label": {
"type": "String"
}
}
},
"failedToSave": "Kaydetme başarısız: {error}",
"@failedToSave": {
"description": "Generic error message for save failures",
@@ -1414,10 +1275,6 @@
"@sarTemplates": {
"description": "SAR templates menu title"
},
"manageSarTemplates": "SAR şablonlarını yönet",
"@manageSarTemplates": {
"description": "Subtitle for SAR templates settings"
},
"addTemplate": "Şablon ekle",
"@addTemplate": {
"description": "Button to add new SAR template"
@@ -1541,10 +1398,6 @@
"@ok": {
"description": "OK button label"
},
"permissionsSection": "İzinler",
"@permissionsSection": {
"description": "Permissions section header"
},
"locationPermission": "Konum izni",
"@locationPermission": {
"description": "Location permission label"
@@ -2109,94 +1962,6 @@
"@wizardWelcomeTitle": {
"description": "Welcome wizard first page title"
},
"wizardWelcomeDescription": "Arama ve kurtarma operasyonları için güçlü bir şebeke dışı iletişim aracı. Geleneksel ağlar kullanılamadığında ekibinizle mesh radyo teknolojisi üzerinden bağlantı kurun.",
"@wizardWelcomeDescription": {
"description": "Welcome wizard first page description"
},
"wizardConnectingTitle": "Radyonuza bağlanma",
"@wizardConnectingTitle": {
"description": "Wizard connecting page title"
},
"wizardConnectingDescription": "Şebeke dışı iletişime başlamak için akıllı telefonunuzu Bluetooth üzerinden bir MeshCore radyo cihazına bağlayın.",
"@wizardConnectingDescription": {
"description": "Wizard connecting page description"
},
"wizardConnectingFeature1": "Yakındaki MeshCore cihazlarını tarayın",
"@wizardConnectingFeature1": {
"description": "Wizard connecting feature 1"
},
"wizardConnectingFeature2": "Radyonuzla Bluetooth üzerinden eşleşin",
"@wizardConnectingFeature2": {
"description": "Wizard connecting feature 2"
},
"wizardConnectingFeature3": "Tamamen çevrimdışı çalışır - internet gerekmez",
"@wizardConnectingFeature3": {
"description": "Wizard connecting feature 3"
},
"wizardChannelTitle": "Kanallar",
"@wizardChannelTitle": {
"description": "Wizard channel page title"
},
"wizardChannelDescription": "Bir kanaldaki herkese mesaj yayınlayın; ekip genelindeki duyurular ve koordinasyon için idealdir.",
"@wizardChannelDescription": {
"description": "Wizard channel page description"
},
"wizardChannelFeature1": "Genel ekip iletişimi için herkese açık kanal",
"@wizardChannelFeature1": {
"description": "Wizard channel feature 1"
},
"wizardChannelFeature2": "Belirli gruplar için özel kanallar oluşturun",
"@wizardChannelFeature2": {
"description": "Wizard channel feature 2"
},
"wizardChannelFeature3": "Mesajlar mesh tarafından otomatik iletilir",
"@wizardChannelFeature3": {
"description": "Wizard channel feature 3"
},
"wizardContactsTitle": "Kişiler",
"@wizardContactsTitle": {
"description": "Wizard contacts page title"
},
"wizardContactsDescription": "Ekip üyeleriniz mesh ağına katıldıkça otomatik olarak görünür. Onlara doğrudan mesaj gönderin veya konumlarını görüntüleyin.",
"@wizardContactsDescription": {
"description": "Wizard contacts page description"
},
"wizardContactsFeature1": "Kişiler otomatik bulunur",
"@wizardContactsFeature1": {
"description": "Wizard contacts feature 1"
},
"wizardContactsFeature2": "Özel doğrudan mesaj gönderin",
"@wizardContactsFeature2": {
"description": "Wizard contacts feature 2"
},
"wizardContactsFeature3": "Pil seviyesini ve son görülme zamanını görüntüleyin",
"@wizardContactsFeature3": {
"description": "Wizard contacts feature 3"
},
"wizardMapTitle": "Harita ve konum",
"@wizardMapTitle": {
"description": "Wizard map page title"
},
"wizardMapDescription": "Ekibinizi gerçek zamanlı izleyin ve arama kurtarma operasyonları için önemli konumları işaretleyin.",
"@wizardMapDescription": {
"description": "Wizard map page description"
},
"wizardMapFeature1": "Bulunan kişiler, yangınlar ve toplanma alanları için SAR işaretleri",
"@wizardMapFeature1": {
"description": "Wizard map feature 1"
},
"wizardMapFeature2": "Ekip üyelerinin gerçek zamanlı GPS takibi",
"@wizardMapFeature2": {
"description": "Wizard map feature 2"
},
"wizardMapFeature3": "Uzak bölgeler için çevrimdışı haritalar indirin",
"@wizardMapFeature3": {
"description": "Wizard map feature 3"
},
"wizardMapFeature4": "Şekiller çizin ve taktik bilgileri paylaşın",
"@wizardMapFeature4": {
"description": "Wizard map feature 4"
},
"viewWelcomeTutorial": "Karşılama eğitimini görüntüle",
"@viewWelcomeTutorial": {
"description": "Settings option to re-show welcome wizard"
@@ -2352,29 +2117,10 @@
"posttxDelay": "Gönderim sonrası gecikme",
"bandpass": "Bant geçiren",
"bandpassFilterVoice": "Bant geçiren filtre ses",
"frequency": "Frekans",
"australia": "Avustralya",
"australiaNarrow": "Avustralya (Dar)",
"australiaQld": "Avustralya: QLD",
"australiaSaWa": "Avustralya: SA, WA",
"newZealand": "Yeni Zelanda",
"newZealandNarrow": "Yeni Zelanda (Dar)",
"switzerland": "İsviçre",
"portugal433": "Portekiz 433",
"portugal868": "Portekiz 868",
"czechRepublicNarrow": "Çek Cumhuriyeti (Dar)",
"eu433mhzLongRange": "AB 433MHz (Uzun Menzil)",
"euukDeprecated": "AB/BK (Kullanımdan kalktı)",
"euukNarrow": "AB/BK (Dar)",
"usacanadaRecommended": "ABD/Kanada (Önerilen)",
"vietnamDeprecated": "Vietnam (Kullanımdan kalktı)",
"vietnamNarrow": "Vietnam (Dar)",
"active": "Aktif",
"addContact": "Kişi ekle",
"all": "Tümü",
"autoResolve": "Otomatik çöz",
"clearAllLabel": "Tümünü temizle",
"clearRelays": "Röleleri temizle",
"clearFilters": "Filtreleri temizle",
"clearRoute": "Rotayı temizle",
"clearMessages": "Mesajları temizle",
@@ -2400,10 +2146,7 @@
"send": "Gönder",
"sendAnyway": "Yine de gönder",
"share": "Paylaş",
"shareContact": "Kişiyi paylaş",
"trace": "İz",
"use": "Kullan",
"useSelectedFrequency": "Seçilen frekansı kullan",
"discovery": "Keşif",
"discoverRepeaters": "Tekrarlayıcıları keşfet",
"discoverSensors": "Sensörleri keşfet",
@@ -2426,7 +2169,6 @@
"gpsModule": "GPS Modülü",
"liveTraffic": "Canlı trafik",
"repeatersMap": "Tekrarlayıcılar haritası",
"spectrumScan": "Spektrum taraması",
"blePacketLogs": "BLE paket günlükleri",
"onlineTraceDatabase": "Çevrimiçi iz veritabanı",
"routePathByteSize": "Yol bayt boyutu",
@@ -2438,7 +2180,6 @@
"disableContacts": "Kişileri devre dışı bırak",
"enableSensorsTab": "Sensörler sekmesini etkinleştir",
"enableProfiles": "Profilleri etkinleştir",
"autoRouteRotation": "Otomatik rota rotasyonu",
"nearestRepeaterFallback": "En yakın tekrarlayıcıya geri dönüş",
"deleteAllStoredMessageHistory": "Tüm mesaj geçmişini sil",
"messageFontSize": "Mesaj yazı tipi boyutu",
@@ -2446,18 +2187,13 @@
"showMapDebugInfo": "Harita hata ayıklama bilgisini göster",
"openMapInFullscreen": "Haritayı tam ekranda aç",
"showSarMarkersLabel": "SAR işaretçilerini göster",
"displaySarMarkersOnTheMainMap": "SAR işaretçilerini ana haritada göster",
"showAllContactTrailsLabel": "Tüm kişi izlerini göster",
"hideRepeatersOnMap": "Haritada tekrarlayıcıları gizle",
"setMapScale": "Harita ölçeğini ayarla",
"customMapScaleSaved": "Özel harita ölçeği kaydedildi",
"voiceBitrate": "Ses bit hızı",
"voiceCompressor": "Ses sıkıştırıcı",
"balancesQuietAndLoudSpeechLevels": "Sessiz ve yüksek konuşma seviyelerini dengeler",
"voiceLimiter": "Ses sınırlayıcı",
"preventsClippingPeaksBeforeEncoding": "Kodlama öncesi tepe kırpmasını önler",
"micAutoGain": "Mikrofon otomatik kazancı",
"letsTheRecorderAdjustInputLevel": "Kaydedicinin giriş seviyesini ayarlamasına izin verir",
"echoCancellation": "Yankı iptali",
"noiseSuppression": "Gürültü bastırma",
"trimSilenceInVoiceMessages": "Sesli mesajlarda sessizliği kırp",
@@ -2499,13 +2235,10 @@
"staging": "Toplanma alanı",
"object": "Nesne",
"quiet": "Sessiz",
"moderate": "Orta",
"busy": "Meşgul",
"spectrumScanReturnedNoCandidateFrequencies": "Spektrum taraması aday frekans bulamadı",
"searchMessages": "Mesajlarda ara",
"sendImageFromGallery": "Galeriden resim gönder",
"takePhoto": "Fotoğraf çek",
"dmOnly": "Yalnızca DM",
"allMessages": "Tüm mesajlar",
"sendToPublicChannel": "Genel kanala gönderilsin mi?",
"selectMarkerTypeAndDestination": "İşaretçi türünü ve hedefi seçin",
@@ -2524,8 +2257,6 @@
"wipeDevice": "Cihazı sil",
"destructiveDeviceActions": "Yıkıcı cihaz işlemleri.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Bir ön ayar seçin veya radyo ayarlarını ince ayarlayın.",
"chooseTheNameAndLocationThisDeviceShares": "Bu cihazın paylaştığı ad ve konumu seçin.",
"availableSpaceOnThisDevice": "Bu cihazda kullanılabilir alan.",
"used": "Kullanılan",
"total": "Toplam",
"renameValue": "Değeri yeniden adlandır",
@@ -2543,8 +2274,6 @@
"onlineOnly": "Yalnızca çevrimiçi",
"inBoth": "Her ikisinde",
"source": "Kaynak",
"manualRouteEdit": "Manuel rota düzenle",
"observedMeshRoute": "Gözlemlenen mesh rotası",
"allMessagesCleared": "Tüm mesajlar temizlendi",
"onlineTraceDatabaseCleared": "Çevrimiçi iz veritabanı temizlendi",
"packetLogsCleared": "Paket günlükleri temizlendi",
@@ -2575,5 +2304,194 @@
"captured": "Yakalandı",
"size": "Boyut",
"noCustomChannelsToClear": "Temizlenecek özel kanal yok.",
"noDeviceContactsToClear": "Temizlenecek cihaz kişisi yok."
"noDeviceContactsToClear": "Temizlenecek cihaz kişisi yok.",
"autoSend": "Otomatik Gönder",
"autoSendDescription": "Mevcut yol üzerinden gönder.",
"sendDirect": "Doğrudan Gönder",
"sendDirectDescription": "Bu kişiye doğrudan gönder.",
"sendFlood": "Taşkın Gönder",
"sendFloodDescription": "Tüm aktarıcılar üzerinden gönder.",
"postConnectDiscoveryTitle": "Aktarıcıları şimdi keşfet?",
"postConnectDiscoveryDescription": "Bağlandıktan hemen sonra aktarıcı keşfi çalıştırarak yakındaki MeshCore düğümlerini görün ve ağınıza daha hızlı ekleyin.",
"aToZ": "A-Z",
"add": "Ekle",
"addAdditionalDetails": "Ek ayrıntılar ekle",
"addPeer": "Eş ekle",
"addPeerManually": "Eşi elle ekle",
"addedToContacts": "{name} kişilere eklendi",
"addedToSensors": "{name} Sensörlere eklendi",
"allowAll": "Tümüne izin ver",
"anonymousRxStats": "Anonim RX istatistikleri",
"appearance": "Görünüm",
"ascii": "ASCII",
"baseTelemetry": "Temel telemetri",
"blePin": "BLE PIN",
"buzzerAlerts": "Buzzer uyarıları",
"cancelImageReceive": "Görüntü alımını iptal et",
"choose": "Seç",
"chooseWhichTabsAndContactSectionsToShow": "Gösterilecek sekmeleri ve kişi bölümlerini seçin",
"clearCache": "Önbelleği temizle",
"clearHistory": "Geçmişi temizle",
"clearLiveView": "Canlı görünümü temizle",
"clearLogs": "Günlükleri temizle",
"clearOfflineCache": "Çevrimdışı önbellek temizlensin mi?",
"clearOverlay": "Katmanı temizle",
"clearRouteOnlyAfterAllRetriesFail": "Rotayı yalnızca tüm yeniden denemeler başarısız olduktan sonra temizle",
"codecBitrateAndAudioProcessing": "Codec, bit hızı ve ses işleme",
"connectByIpAddress": "IP adresiyle bağlan",
"controlWhichAlertsYouReceive": "Hangi uyarıları alacağınızı kontrol edin",
"copyFullHex": "Tam Hex kopyala",
"copyRawResponse": "Ham yanıtı kopyala",
"couldNotLoadMetHistory": "MET geçmişi yüklenemedi",
"customizeSensor": "{name} sensörünü özelleştir",
"data": "Veri",
"deleteGroup": "Grubu sil",
"deleteStyleConfirm": "{name} silinsin mi?",
"deleteThisDrawing": "Bu {type} silinsin mi?",
"deny": "Reddet",
"deviceInfo": "Cihaz bilgisi",
"deviceSettings": "Cihaz Ayarları",
"deviceTimeSynced": "Cihaz saati bu telefonla senkronize edildi.",
"disableFastGpsPublishing": "Hızlı GPS yayınını devre dışı bırak",
"displayMarkersAndTraceDatabase": "Ekran, işaretçiler ve iz veritabanı",
"download": "İndir",
"environmentalTelemetry": "Çevresel telemetri",
"errorCheckingForUpdates": "Güncelleme kontrolünde hata: {error}",
"errorGeneric": "Hata: {error}",
"errorOpeningDownload": "İndirme açılırken hata: {error}",
"exportLogs": "Günlükleri dışa aktar",
"extraAggressiveAvifCompression": "Ekstra agresif AVIF sıkıştırma",
"failedToAddContact": "Kişi eklenemedi: {error}",
"failedToAddName": "{name} eklenemedi: {error}",
"failedToClearChannels": "Kanallar temizlenemedi: {error}",
"failedToClearContacts": "Kişiler temizlenemedi: {error}",
"failedToConnectViaSerial": "Seri bağlantı kurulamadı",
"failedToLoadPreviewImage": "Önizleme görüntüsü yüklenemedi: {error}",
"failedToLoadTrace": "İz yüklenemedi: {error}",
"failedToSendSarMarker": "SAR işaretçisi gönderilemedi: {error}",
"failedToSetBuzzerMode": "Buzzer modu ayarlanamadı: {error}",
"failedToSetGpsMode": "GPS modu ayarlanamadı: {error}",
"failedToSyncDeviceTime": "Cihaz saati senkronize edilemedi: {error}",
"failedToWipeDeviceData": "Cihaz verileri silinemedi: {error}",
"fastGpsTargetChannel": "Hızlı GPS hedef kanalı",
"fastUpdatesThresholdsAndPermissions": "Hızlı güncellemeler, eşikler ve izinler",
"fetchingCatalog": "Katalog getiriliyor...",
"fifteenMinutes": "15 dakika",
"fifteenSeconds": "15 saniye",
"filterByDirection": "Yöne göre filtrele",
"finish": "Bitir",
"fiveMinutes": "5 dakika",
"fiveSeconds": "5 saniye",
"fix": "Düzelt",
"gps": "GPS",
"gpsAdvertPolicy": "GPS ilan politikası",
"gpsAndLocation": "GPS ve Konum",
"gpsDiagnostics": "GPS tanılama",
"gpsSection": "GPS",
"help": "Yardım",
"hex": "Hex",
"hidden": "Gizli",
"howTheRadioAutoAddsDiscoveredNodes": "Radyonun keşfedilen düğümleri otomatik olarak nasıl eklediği.",
"images": "Görüntüler",
"importProfile": "Profili içe aktar",
"lastFix": "Son konum",
"latest": "En yeni",
"latitude": "Enlem",
"loadASavedRegion": "Kayıtlı bir bölge yükle",
"loading": "Yükleniyor",
"locationSharingHardwareAndUpdateInterval": "Konum paylaşımı, donanım ve güncelleme aralığı.",
"locationTelemetry": "Konum telemetrisi",
"lockDestination": "Hedefi kilitle",
"longitude": "Boylam",
"mapStyle": "Harita Stili",
"max": "Maks",
"maxZoom": "Maks Yakınlaştırma",
"messaging": "Mesajlaşma",
"metHistory": "MET geçmişi",
"min": "Min",
"minZoom": "Min Yakınlaştırma",
"moveDown": "Aşağı taşı",
"moveUp": "Yukarı taşı",
"multiAckMode": "Çoklu ACK modu",
"multiDeviceWorkspaceManagement": "Çoklu cihaz çalışma alanı yönetimi",
"myLocation": "Konumum",
"nameAndTelemetryShared": "Ad ve telemetri diğer cihazlarla paylaşılır.",
"newVersionAvailable": "Yeni sürüm: {version}",
"newer": "Daha yeni",
"noNeighboursFound": "Komşu bulunamadı",
"noServersFound": "Sunucu bulunamadı",
"notSet": "Ayarlanmadı",
"notifications": "Bildirimler",
"offZeroSeconds": "Kapalı (0 sn)",
"offlineMaps": "Çevrimdışı Haritalar",
"older": "Daha eski",
"oneByteMode0": "1 bayt (mod 0)",
"oneHour": "1 saat",
"oneMinute": "1 dakika",
"openPacketLogs": "Paket günlüklerini aç",
"packetTypeHelp": "Paket türü yardımı",
"pasteShareLinkOrHexAdvert": "Paylaşım bağlantısı veya Hex ilanı yapıştırın",
"pathHash": "Yol hash",
"pathSize": "Yol boyutu",
"ping": "Ping",
"polygon": "Çokgen",
"rawResponseCopied": "Ham yanıt kopyalandı",
"resendThroughNearestRepeaterOnFailure": "Başarısızlık durumunda en yakın aktarıcı üzerinden yeniden gönder",
"resolveContact": "Kişiyi çözümle",
"routingRetriesAndDestinationLock": "Yönlendirme, yeniden denemeler ve hedef kilidi",
"samples": "Örnekler",
"satellites": "Uydular",
"saving": "Kaydediliyor...",
"search": "Ara",
"searchByNameOrToken": "Ada veya jetona göre ara",
"searchDiscoveredNodes": "Keşfedilen düğümleri ara",
"searchInCurrentFilter": "Geçerli filtrede ara",
"searchLogs": "Günlüklerde ara...",
"searchSensors": "Sensörleri ara",
"searchThisSection": "Bu bölümde ara",
"selectedRelays": "Seçili aktarıcılar",
"sensor": "Sensör",
"serialError": "Seri hata: {error}",
"setPathForContact": "{name} için yol ayarla",
"shareLiveGps": "Canlı GPS paylaş",
"shareMyTiles": "Döşemelerimi paylaş",
"sharingLocationFromPhone": "Bu kanalda telefondan konum paylaşılıyor.",
"sharingLocationFromRadio": "Bu kanalda radyodan konum paylaşılıyor.",
"signalDbm": "Sinyal {rssi} dBm",
"sizeCompressionAndPreview": "Boyut, sıkıştırma ve önizleme",
"skip": "Atla",
"smallerFileSize": "Daha küçük dosya boyutu",
"sort": "Sırala",
"stoppedSharingLocation": "Bu kanalda konum paylaşımı durduruldu.",
"suppressNotificationsWhileInForeground": "Ön plandayken bildirimleri bastır",
"tabsAndNavigation": "Sekmeler ve Gezinme",
"templatesAndTutorials": "Şablonlar ve öğreticiler",
"tenMinutes": "10 dakika",
"tenSeconds": "10 saniye",
"testSendUpdate": "Test güncelleme gönder",
"themeLanguageAndDisplayPreferences": "Tema, dil ve görüntüleme tercihleri",
"thirtyMinutes": "30 dakika",
"thirtySeconds": "30 saniye",
"threeBytesMode2": "3 bayt (mod 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Trafik istatistikleri, mesaj geçmişi ve geliştirici araçları",
"twoBytesMode1": "2 bayt (mod 1)",
"twoMinutes": "2 dakika",
"undo": "Geri al",
"updated": "Güncellendi",
"useContactFlags": "Kişi bayraklarını kullan",
"useSavedCoordinates": "Kayıtlı koordinatları kullan",
"view": "Görüntüle",
"viewNeighbours": "Komşuları Görüntüle",
"viewPublicStats": "Herkese açık istatistikleri görüntüle",
"setRegionScope": "Bölge kapsamını ayarla",
"regionScope": "Bölge kapsamı",
"regionScopeNone": "Yok (genel)",
"clearRegionScope": "Kapsamı temizle",
"regionScopeWarning": "Yalnızca bu bölgeye izin veren aktarıcılar iletecektir.",
"discoverRegions": "Aktarıcılardan keşfet",
"discoveringRegions": "Bölgeler keşfediliyor...",
"enterRegionName": "Bölge adı (ör. auckland)",
"noRegionsFound": "Bu aktarıcıda bölge bulunamadı.",
"regionScopeSet": "Bölge kapsamı {name} olarak ayarlandı",
"regionScopeCleared": "Bölge kapsamı temizlendi"
}

View File

@@ -36,10 +36,6 @@
"@scanAgain": {
"description": "Button to restart BLE scanning"
},
"tapToConnect": "Торкніться, щоб підключитися",
"@tapToConnect": {
"description": "Subtitle text for device in scan list"
},
"deviceNotConnected": "Пристрій не підключено",
"@deviceNotConnected": {
"description": "Error message when device is not connected"
@@ -133,18 +129,10 @@
"@showRxTxIndicators": {
"description": "Setting to show RX/TX indicators"
},
"displayPacketActivity": "Показувати індикатори активності пакетів у верхній панелі",
"@displayPacketActivity": {
"description": "Description for RX/TX indicators setting"
},
"disableMap": "Вимкнути мапу",
"@disableMap": {
"description": "Setting to disable the map tab"
},
"disableMapDescription": "Приховати вкладку мапи для зменшення споживання батареї",
"@disableMapDescription": {
"description": "Description for disable map setting"
},
"language": "Мова",
"@language": {
"description": "Language setting label"
@@ -173,10 +161,6 @@
"@appVersion": {
"description": "App version label"
},
"appName": "Назва застосунку",
"@appName": {
"description": "App name label"
},
"aboutMeshCoreSar": "Про MeshCore SAR",
"@aboutMeshCoreSar": {
"description": "About dialog title"
@@ -308,10 +292,6 @@
"@resetPath": {
"description": "Action to reset contact path for re-routing"
},
"publicKeyCopied": "Публічний ключ скопійовано в буфер обміну",
"@publicKeyCopied": {
"description": "Success message when public key is copied"
},
"copiedToClipboard": "{label} скопійовано в буфер обміну",
"@copiedToClipboard": {
"description": "Success message when a value is copied to clipboard",
@@ -720,22 +700,6 @@
"@lastSeen": {
"description": "Last seen label"
},
"roomStatus": "Стан кімнати",
"@roomStatus": {
"description": "Room status section header"
},
"loginStatus": "Стан входу",
"@loginStatus": {
"description": "Login status label"
},
"notLoggedIn": "Не увійшли",
"@notLoggedIn": {
"description": "Not logged in status"
},
"adminAccess": "Доступ адміністратора",
"@adminAccess": {
"description": "Admin access label"
},
"yes": "Так",
"@yes": {
"description": "Yes answer"
@@ -748,14 +712,6 @@
"@permissions": {
"description": "Permissions label"
},
"passwordSaved": "Пароль збережено",
"@passwordSaved": {
"description": "Password saved label"
},
"locationColon": "Місцезнаходження:",
"@locationColon": {
"description": "Location section header"
},
"telemetry": "Телеметрія",
"@telemetry": {
"description": "Telemetry section header"
@@ -784,19 +740,6 @@
"@gpsTelemetry": {
"description": "GPS from telemetry label"
},
"updated": "Оновлено",
"@updated": {
"description": "Updated timestamp label"
},
"pathResetInfo": "Маршрут для {name} скинуто. Наступне повідомлення знайде новий маршрут.",
"@pathResetInfo": {
"description": "Info message after path reset",
"placeholders": {
"name": {
"type": "String"
}
}
},
"reLoginToRoom": "Повторно увійти до кімнати",
"@reLoginToRoom": {
"description": "Button to re-login to room"
@@ -1158,10 +1101,6 @@
"@frequencyMHz": {
"description": "Label for radio frequency field"
},
"frequencyExample": "напр., 869.618",
"@frequencyExample": {
"description": "Helper text example for frequency"
},
"bandwidth": "Ширина смуги",
"@bandwidth": {
"description": "Label for bandwidth dropdown"
@@ -1265,47 +1204,6 @@
"@trailControls": {
"description": "Trail controls tooltip"
},
"contactTrails": "Сліди контактів",
"@contactTrails": {
"description": "Contact trails section header"
},
"showAllContactTrails": "Показати всі сліди контактів",
"@showAllContactTrails": {
"description": "Toggle label to show all contact trails"
},
"noContactsWithLocationHistory": "Немає контактів з історією місцезнаходження",
"@noContactsWithLocationHistory": {
"description": "Subtitle when no contacts have trails"
},
"showingTrailsForContacts": "Показуються сліди для {count} контактів",
"@showingTrailsForContacts": {
"description": "Subtitle showing number of contacts with trails",
"placeholders": {
"count": {
"type": "int"
}
}
},
"individualContactTrails": "Окремі сліди контактів",
"@individualContactTrails": {
"description": "Expansion tile title for individual contact trails"
},
"deviceInformation": "Інформація про пристрій",
"@deviceInformation": {
"description": "Device information section header"
},
"bleName": "Назва BLE",
"@bleName": {
"description": "Bluetooth Low Energy device name label"
},
"meshName": "Назва mesh",
"@meshName": {
"description": "Mesh network name label"
},
"notSet": "Не задано",
"@notSet": {
"description": "Label when a value is not set"
},
"model": "Модель",
"@model": {
"description": "Device model label"
@@ -1314,14 +1212,6 @@
"@version": {
"description": "Version label"
},
"buildDate": "Дата збірки",
"@buildDate": {
"description": "Firmware build date label"
},
"firmware": "Прошивка",
"@firmware": {
"description": "Firmware label"
},
"maxContacts": "Макс. контактів",
"@maxContacts": {
"description": "Maximum contacts capacity label"
@@ -1334,26 +1224,6 @@
"@publicInfo": {
"description": "Public information section header"
},
"meshNetworkName": "Назва mesh-мережі",
"@meshNetworkName": {
"description": "Mesh network name field label"
},
"nameBroadcastInMesh": "Ім’я, що транслюється в mesh-оголошеннях",
"@nameBroadcastInMesh": {
"description": "Helper text for mesh network name field"
},
"telemetryAndLocationSharing": "Телеметрія та поширення місцезнаходження",
"@telemetryAndLocationSharing": {
"description": "Telemetry and location sharing toggle label"
},
"lat": "Шир.",
"@lat": {
"description": "Latitude field label (short form)"
},
"lon": "Довг.",
"@lon": {
"description": "Longitude field label (short form)"
},
"useCurrentLocation": "Використати поточне місцезнаходження",
"@useCurrentLocation": {
"description": "Tooltip for use current location button"
@@ -1383,15 +1253,6 @@
}
}
},
"copiedToClipboardShort": "{label} скопійовано в буфер обміну",
"@copiedToClipboardShort": {
"description": "Short success message when copying to clipboard",
"placeholders": {
"label": {
"type": "String"
}
}
},
"failedToSave": "Не вдалося зберегти: {error}",
"@failedToSave": {
"description": "Generic error message for save failures",
@@ -1414,10 +1275,6 @@
"@sarTemplates": {
"description": "SAR templates menu title"
},
"manageSarTemplates": "Керувати шаблонами SAR",
"@manageSarTemplates": {
"description": "Subtitle for SAR templates settings"
},
"addTemplate": "Додати шаблон",
"@addTemplate": {
"description": "Button to add new SAR template"
@@ -1541,10 +1398,6 @@
"@ok": {
"description": "OK button label"
},
"permissionsSection": "Дозволи",
"@permissionsSection": {
"description": "Permissions section header"
},
"locationPermission": "Дозвіл на геолокацію",
"@locationPermission": {
"description": "Location permission label"
@@ -2109,94 +1962,6 @@
"@wizardWelcomeTitle": {
"description": "Welcome wizard first page title"
},
"wizardWelcomeDescription": "Потужний інструмент офлайн-комунікації для операцій пошуку та рятування. Зв’язуйтеся зі своєю командою за допомогою mesh-радіо, коли традиційні мережі недоступні.",
"@wizardWelcomeDescription": {
"description": "Welcome wizard first page description"
},
"wizardConnectingTitle": "Підключення до радіо",
"@wizardConnectingTitle": {
"description": "Wizard connecting page title"
},
"wizardConnectingDescription": "Підключіть смартфон до радіопристрою MeshCore через Bluetooth, щоб почати спілкування поза мережею.",
"@wizardConnectingDescription": {
"description": "Wizard connecting page description"
},
"wizardConnectingFeature1": "Скануйте сусідні пристрої MeshCore",
"@wizardConnectingFeature1": {
"description": "Wizard connecting feature 1"
},
"wizardConnectingFeature2": "Сполучіть свій радіопристрій через Bluetooth",
"@wizardConnectingFeature2": {
"description": "Wizard connecting feature 2"
},
"wizardConnectingFeature3": "Працює повністю офлайн - інтернет не потрібен",
"@wizardConnectingFeature3": {
"description": "Wizard connecting feature 3"
},
"wizardChannelTitle": "Канали",
"@wizardChannelTitle": {
"description": "Wizard channel page title"
},
"wizardChannelDescription": "Транслюйте повідомлення всім у каналі - ідеально для загальнокомандних оголошень і координації.",
"@wizardChannelDescription": {
"description": "Wizard channel page description"
},
"wizardChannelFeature1": "Публічний канал для загального спілкування команди",
"@wizardChannelFeature1": {
"description": "Wizard channel feature 1"
},
"wizardChannelFeature2": "Створюйте власні канали для окремих груп",
"@wizardChannelFeature2": {
"description": "Wizard channel feature 2"
},
"wizardChannelFeature3": "Повідомлення автоматично ретранслюються mesh-мережею",
"@wizardChannelFeature3": {
"description": "Wizard channel feature 3"
},
"wizardContactsTitle": "Контакти",
"@wizardContactsTitle": {
"description": "Wizard contacts page title"
},
"wizardContactsDescription": "Члени вашої команди з’являються автоматично, коли приєднуються до mesh-мережі. Надсилайте їм прямі повідомлення або переглядайте їхнє місцезнаходження.",
"@wizardContactsDescription": {
"description": "Wizard contacts page description"
},
"wizardContactsFeature1": "Контакти виявляються автоматично",
"@wizardContactsFeature1": {
"description": "Wizard contacts feature 1"
},
"wizardContactsFeature2": "Надсилайте приватні прямі повідомлення",
"@wizardContactsFeature2": {
"description": "Wizard contacts feature 2"
},
"wizardContactsFeature3": "Переглядайте рівень батареї та час останньої появи",
"@wizardContactsFeature3": {
"description": "Wizard contacts feature 3"
},
"wizardMapTitle": "Мапа та місцезнаходження",
"@wizardMapTitle": {
"description": "Wizard map page title"
},
"wizardMapDescription": "Відстежуйте команду в реальному часі та позначайте важливі місця для операцій пошуку та рятування.",
"@wizardMapDescription": {
"description": "Wizard map page description"
},
"wizardMapFeature1": "Маркери SAR для знайдених людей, пожеж і зон збору",
"@wizardMapFeature1": {
"description": "Wizard map feature 1"
},
"wizardMapFeature2": "GPS-відстеження членів команди в реальному часі",
"@wizardMapFeature2": {
"description": "Wizard map feature 2"
},
"wizardMapFeature3": "Завантажуйте офлайн-мапи для віддалених районів",
"@wizardMapFeature3": {
"description": "Wizard map feature 3"
},
"wizardMapFeature4": "Малюйте фігури та діліться тактичною інформацією",
"@wizardMapFeature4": {
"description": "Wizard map feature 4"
},
"viewWelcomeTutorial": "Переглянути вступний посібник",
"@viewWelcomeTutorial": {
"description": "Settings option to re-show welcome wizard"
@@ -2352,29 +2117,10 @@
"posttxDelay": "Затримка після передачі",
"bandpass": "Смуговий",
"bandpassFilterVoice": "Смуговий фільтр голосу",
"frequency": "Частота",
"australia": "Австралія",
"australiaNarrow": "Австралія (Вузький)",
"australiaQld": "Австралія: QLD",
"australiaSaWa": "Австралія: SA, WA",
"newZealand": "Нова Зеландія",
"newZealandNarrow": "Нова Зеландія (Вузький)",
"switzerland": "Швейцарія",
"portugal433": "Португалія 433",
"portugal868": "Португалія 868",
"czechRepublicNarrow": "Чехія (Вузький)",
"eu433mhzLongRange": "ЄС 433МГц (Далекий)",
"euukDeprecated": "ЄС/Великобританія (Застаріле)",
"euukNarrow": "ЄС/Великобританія (Вузький)",
"usacanadaRecommended": "США/Канада (Рекомендовано)",
"vietnamDeprecated": "В'єтнам (Застаріле)",
"vietnamNarrow": "В'єтнам (Вузький)",
"active": "Активно",
"addContact": "Додати контакт",
"all": "Усі",
"autoResolve": "Автоматичне вирішення",
"clearAllLabel": "Очистити все",
"clearRelays": "Очистити реле",
"clearFilters": "Очистити фільтри",
"clearRoute": "Очистити маршрут",
"clearMessages": "Очистити повідомлення",
@@ -2400,10 +2146,7 @@
"send": "Надіслати",
"sendAnyway": "Надіслати все одно",
"share": "Поділитися",
"shareContact": "Поділитися контактом",
"trace": "Трасування",
"use": "Використати",
"useSelectedFrequency": "Використати обрану частоту",
"discovery": "Виявлення",
"discoverRepeaters": "Виявити повторювачі",
"discoverSensors": "Виявити датчики",
@@ -2426,7 +2169,6 @@
"gpsModule": "Модуль GPS",
"liveTraffic": "Живий трафік",
"repeatersMap": "Карта повторювачів",
"spectrumScan": "Сканування спектру",
"blePacketLogs": "Журнали BLE-пакетів",
"onlineTraceDatabase": "База трасувань",
"routePathByteSize": "Розмір шляху в байтах",
@@ -2438,7 +2180,6 @@
"disableContacts": "Вимкнути контакти",
"enableSensorsTab": "Увімкнути вкладку Датчики",
"enableProfiles": "Увімкнути профілі",
"autoRouteRotation": "Автоматична ротація маршруту",
"nearestRepeaterFallback": "Найближчий повторювач як резерв",
"deleteAllStoredMessageHistory": "Видалити всю історію повідомлень",
"messageFontSize": "Розмір шрифту повідомлень",
@@ -2446,18 +2187,13 @@
"showMapDebugInfo": "Показати налагоджувальну інфо карти",
"openMapInFullscreen": "Відкрити карту на повний екран",
"showSarMarkersLabel": "Показати SAR-маркери",
"displaySarMarkersOnTheMainMap": "Показувати SAR-маркери на головній карті",
"showAllContactTrailsLabel": "Показати всі сліди контактів",
"hideRepeatersOnMap": "Сховати повторювачі на карті",
"setMapScale": "Встановити масштаб карти",
"customMapScaleSaved": "Користувацький масштаб карти збережено",
"voiceBitrate": "Бітрейт голосу",
"voiceCompressor": "Компресор голосу",
"balancesQuietAndLoudSpeechLevels": "Вирівнює тихий і гучний рівні мови",
"voiceLimiter": "Лімітер голосу",
"preventsClippingPeaksBeforeEncoding": "Запобігає обрізанню піків перед кодуванням",
"micAutoGain": "Автопідсилення мікрофона",
"letsTheRecorderAdjustInputLevel": "Дозволяє рекордеру налаштувати рівень входу",
"echoCancellation": "Придушення ехо",
"noiseSuppression": "Придушення шуму",
"trimSilenceInVoiceMessages": "Обрізати тишу в голосових повідомленнях",
@@ -2499,13 +2235,10 @@
"staging": "Місце збору",
"object": "Об'єкт",
"quiet": "Тихо",
"moderate": "Помірно",
"busy": "Зайнято",
"spectrumScanReturnedNoCandidateFrequencies": "Сканування спектру не знайшло частот-кандидатів",
"searchMessages": "Пошук повідомлень",
"sendImageFromGallery": "Надіслати зображення з галереї",
"takePhoto": "Зробити фото",
"dmOnly": "Лише особисті повідомлення",
"allMessages": "Усі повідомлення",
"sendToPublicChannel": "Надіслати в публічний канал?",
"selectMarkerTypeAndDestination": "Виберіть тип маркера та призначення",
@@ -2524,8 +2257,6 @@
"wipeDevice": "Стерти пристрій",
"destructiveDeviceActions": "Деструктивні дії пристрою.",
"chooseAPresetOrFinetuneCustomRadioSettings": "Оберіть пресет або налаштуйте радіо вручну.",
"chooseTheNameAndLocationThisDeviceShares": "Оберіть ім'я та місцеположення, якими ділиться пристрій.",
"availableSpaceOnThisDevice": "Доступний простір на цьому пристрої.",
"used": "Використано",
"total": "Загалом",
"renameValue": "Перейменувати значення",
@@ -2543,8 +2274,6 @@
"onlineOnly": "Лише онлайн",
"inBoth": "В обох",
"source": "Джерело",
"manualRouteEdit": "Ручне редагування маршруту",
"observedMeshRoute": "Спостережуваний mesh-маршрут",
"allMessagesCleared": "Усі повідомлення очищено",
"onlineTraceDatabaseCleared": "Базу трасувань очищено",
"packetLogsCleared": "Журнали пакетів очищено",
@@ -2575,5 +2304,194 @@
"captured": "Захоплено",
"size": "Розмір",
"noCustomChannelsToClear": "Немає користувацьких каналів для очищення.",
"noDeviceContactsToClear": "Немає контактів пристрою для очищення."
"noDeviceContactsToClear": "Немає контактів пристрою для очищення.",
"autoSend": "Автовідправлення",
"autoSendDescription": "Надіслати поточним маршрутом.",
"sendDirect": "Пряме надсилання",
"sendDirectDescription": "Надіслати безпосередньо цьому контакту.",
"sendFlood": "Надсилання лавиною",
"sendFloodDescription": "Надіслати через усі ретранслятори.",
"postConnectDiscoveryTitle": "Виявити ретранслятори зараз?",
"postConnectDiscoveryDescription": "Запустіть виявлення ретрансляторів одразу після підключення, щоб побачити найближчі вузли MeshCore та швидше додати їх до своєї мережі.",
"aToZ": "А-Я",
"add": "Додати",
"addAdditionalDetails": "Додати додаткові деталі",
"addPeer": "Додати вузол",
"addPeerManually": "Додати вузол вручну",
"addedToContacts": "{name} додано до контактів",
"addedToSensors": "{name} додано до сенсорів",
"allowAll": "Дозволити все",
"anonymousRxStats": "Анонімна статистика RX",
"appearance": "Зовнішній вигляд",
"ascii": "ASCII",
"baseTelemetry": "Базова телеметрія",
"blePin": "BLE PIN",
"buzzerAlerts": "Сповіщення зумером",
"cancelImageReceive": "Скасувати отримання зображення",
"choose": "Обрати",
"chooseWhichTabsAndContactSectionsToShow": "Оберіть, які вкладки та розділи контактів показувати",
"clearCache": "Очистити кеш",
"clearHistory": "Очистити історію",
"clearLiveView": "Очистити живий перегляд",
"clearLogs": "Очистити журнали",
"clearOfflineCache": "Очистити офлайн-кеш?",
"clearOverlay": "Очистити накладання",
"clearRouteOnlyAfterAllRetriesFail": "Очистити маршрут лише після невдачі всіх спроб",
"codecBitrateAndAudioProcessing": "Кодек, бітрейт та обробка звуку",
"connectByIpAddress": "Підключити за IP-адресою",
"controlWhichAlertsYouReceive": "Керуйте сповіщеннями, які ви отримуєте",
"copyFullHex": "Копіювати повний Hex",
"copyRawResponse": "Копіювати необроблену відповідь",
"couldNotLoadMetHistory": "Не вдалося завантажити історію MET",
"customizeSensor": "Налаштувати {name}",
"data": "Дані",
"deleteGroup": "Видалити групу",
"deleteStyleConfirm": "Видалити {name}?",
"deleteThisDrawing": "Видалити цей {type}?",
"deny": "Відхилити",
"deviceInfo": "Інформація про пристрій",
"deviceSettings": "Налаштування пристрою",
"deviceTimeSynced": "Час пристрою синхронізовано з цим телефоном.",
"disableFastGpsPublishing": "Вимкнути швидку публікацію GPS",
"displayMarkersAndTraceDatabase": "Відображення, маркери та база даних трасувань",
"download": "Завантажити",
"environmentalTelemetry": "Телеметрія довкілля",
"errorCheckingForUpdates": "Помилка перевірки оновлень: {error}",
"errorGeneric": "Помилка: {error}",
"errorOpeningDownload": "Помилка відкриття завантаження: {error}",
"exportLogs": "Експортувати журнали",
"extraAggressiveAvifCompression": "Надагресивне стиснення AVIF",
"failedToAddContact": "Не вдалося додати контакт: {error}",
"failedToAddName": "Не вдалося додати {name}: {error}",
"failedToClearChannels": "Не вдалося очистити канали: {error}",
"failedToClearContacts": "Не вдалося очистити контакти: {error}",
"failedToConnectViaSerial": "Не вдалося підключитися через послідовний порт",
"failedToLoadPreviewImage": "Не вдалося завантажити попередній перегляд: {error}",
"failedToLoadTrace": "Не вдалося завантажити трасування: {error}",
"failedToSendSarMarker": "Не вдалося надіслати SAR маркер: {error}",
"failedToSetBuzzerMode": "Не вдалося встановити режим зумера: {error}",
"failedToSetGpsMode": "Не вдалося встановити режим GPS: {error}",
"failedToSyncDeviceTime": "Не вдалося синхронізувати час пристрою: {error}",
"failedToWipeDeviceData": "Не вдалося стерти дані пристрою: {error}",
"fastGpsTargetChannel": "Цільовий канал швидкого GPS",
"fastUpdatesThresholdsAndPermissions": "Швидкі оновлення, пороги та дозволи",
"fetchingCatalog": "Завантаження каталогу...",
"fifteenMinutes": "15 хвилин",
"fifteenSeconds": "15 секунд",
"filterByDirection": "Фільтрувати за напрямком",
"finish": "Завершити",
"fiveMinutes": "5 хвилин",
"fiveSeconds": "5 секунд",
"fix": "Фіксація",
"gps": "GPS",
"gpsAdvertPolicy": "Політика оголошень GPS",
"gpsAndLocation": "GPS та місцезнаходження",
"gpsDiagnostics": "Діагностика GPS",
"gpsSection": "GPS",
"help": "Допомога",
"hex": "Hex",
"hidden": "Приховано",
"howTheRadioAutoAddsDiscoveredNodes": "Як радіо автоматично додає виявлені вузли.",
"images": "Зображення",
"importProfile": "Імпортувати профіль",
"lastFix": "Остання фіксація",
"latest": "Найновіші",
"latitude": "Широта",
"loadASavedRegion": "Завантажити збережений регіон",
"loading": "Завантаження",
"locationSharingHardwareAndUpdateInterval": "Обмін місцезнаходженням, обладнання та інтервал оновлення.",
"locationTelemetry": "Телеметрія місцезнаходження",
"lockDestination": "Зафіксувати призначення",
"longitude": "Довгота",
"mapStyle": "Стиль мапи",
"max": "Макс",
"maxZoom": "Макс. масштаб",
"messaging": "Обмін повідомленнями",
"metHistory": "Історія MET",
"min": "Мін",
"minZoom": "Мін. масштаб",
"moveDown": "Перемістити вниз",
"moveUp": "Перемістити вгору",
"multiAckMode": "Режим мульти-ACK",
"multiDeviceWorkspaceManagement": "Керування робочим простором кількох пристроїв",
"myLocation": "Моє місцезнаходження",
"nameAndTelemetryShared": "Ім'я та телеметрія надсилаються іншим пристроям.",
"newVersionAvailable": "Нова версія: {version}",
"newer": "Новіші",
"noNeighboursFound": "Сусідів не знайдено",
"noServersFound": "Серверів не знайдено",
"notSet": "Не встановлено",
"notifications": "Сповіщення",
"offZeroSeconds": "Вимк (0 с)",
"offlineMaps": "Офлайн-мапи",
"older": "Старіші",
"oneByteMode0": "1 байт (режим 0)",
"oneHour": "1 година",
"oneMinute": "1 хвилина",
"openPacketLogs": "Відкрити журнали пакетів",
"packetTypeHelp": "Довідка типів пакетів",
"pasteShareLinkOrHexAdvert": "Вставте посилання або hex-оголошення",
"pathHash": "Хеш шляху",
"pathSize": "Розмір шляху",
"ping": "Ping",
"polygon": "Полігон",
"rawResponseCopied": "Необроблену відповідь скопійовано",
"resendThroughNearestRepeaterOnFailure": "Повторно надіслати через найближчий ретранслятор у разі невдачі",
"resolveContact": "Розв'язати контакт",
"routingRetriesAndDestinationLock": "Маршрутизація, повторні спроби та фіксація призначення",
"samples": "Зразки",
"satellites": "Супутники",
"saving": "Збереження...",
"search": "Пошук",
"searchByNameOrToken": "Шукати за іменем або токеном",
"searchDiscoveredNodes": "Шукати виявлені вузли",
"searchInCurrentFilter": "Шукати в поточному фільтрі",
"searchLogs": "Шукати в журналах...",
"searchSensors": "Шукати сенсори",
"searchThisSection": "Шукати в цьому розділі",
"selectedRelays": "Обрані ретранслятори",
"sensor": "Сенсор",
"serialError": "Помилка послідовного порту: {error}",
"setPathForContact": "Встановити шлях для {name}",
"shareLiveGps": "Поділитися GPS у реальному часі",
"shareMyTiles": "Поділитися моїми тайлами",
"sharingLocationFromPhone": "Обмін місцезнаходженням на цьому каналі з телефону.",
"sharingLocationFromRadio": "Обмін місцезнаходженням на цьому каналі з радіо.",
"signalDbm": "Сигнал {rssi} дБм",
"sizeCompressionAndPreview": "Розмір, стиснення та попередній перегляд",
"skip": "Пропустити",
"smallerFileSize": "Менший розмір файлу",
"sort": "Сортувати",
"stoppedSharingLocation": "Обмін місцезнаходженням на цьому каналі зупинено.",
"suppressNotificationsWhileInForeground": "Придушити сповіщення, коли застосунок на передньому плані",
"tabsAndNavigation": "Вкладки та навігація",
"templatesAndTutorials": "Шаблони та посібники",
"tenMinutes": "10 хвилин",
"tenSeconds": "10 секунд",
"testSendUpdate": "Тестове надсилання оновлення",
"themeLanguageAndDisplayPreferences": "Тема, мова та налаштування відображення",
"thirtyMinutes": "30 хвилин",
"thirtySeconds": "30 секунд",
"threeBytesMode2": "3 байти (режим 2)",
"trafficStatsMessageHistoryAndDeveloperTools": "Статистика трафіку, історія повідомлень та інструменти розробника",
"twoBytesMode1": "2 байти (режим 1)",
"twoMinutes": "2 хвилини",
"undo": "Скасувати",
"updated": "Оновлено",
"useContactFlags": "Використовувати прапорці контактів",
"useSavedCoordinates": "Використати збережені координати",
"view": "Перегляд",
"viewNeighbours": "Переглянути сусідів",
"viewPublicStats": "Переглянути публічну статистику",
"setRegionScope": "Встановити область регіону",
"regionScope": "Область регіону",
"regionScopeNone": "Немає (глобально)",
"clearRegionScope": "Очистити область",
"regionScopeWarning": "Лише ретранслятори, що дозволяють цей регіон, будуть пересилати.",
"discoverRegions": "Виявити з ретрансляторів",
"discoveringRegions": "Виявлення регіонів...",
"enterRegionName": "Назва регіону (напр. auckland)",
"noRegionsFound": "Регіони не знайдені на цьому ретрансляторі.",
"regionScopeSet": "Область регіону встановлена на {name}",
"regionScopeCleared": "Область регіону очищена"
}

View File

@@ -36,10 +36,6 @@
"@scanAgain": {
"description": "重新开始 BLE 扫描的按钮"
},
"tapToConnect": "点击连接",
"@tapToConnect": {
"description": "扫描列表中设备的副标题文本"
},
"deviceNotConnected": "设备未连接",
"@deviceNotConnected": {
"description": "设备未连接时的错误消息"
@@ -133,18 +129,10 @@
"@showRxTxIndicators": {
"description": "显示 RX/TX 指示器的设置"
},
"displayPacketActivity": "在顶部栏显示数据包活动指示器",
"@displayPacketActivity": {
"description": "RX/TX 指示器设置的描述"
},
"disableMap": "禁用地图",
"@disableMap": {
"description": "禁用地图标签页的设置"
},
"disableMapDescription": "隐藏地图标签页以减少电池消耗",
"@disableMapDescription": {
"description": "禁用地图设置的描述"
},
"language": "语言",
"@language": {
"description": "语言设置标签"
@@ -173,10 +161,6 @@
"@appVersion": {
"description": "应用版本标签"
},
"appName": "应用名称",
"@appName": {
"description": "应用名称标签"
},
"aboutMeshCoreSar": "关于 MeshCore 搜救助手",
"@aboutMeshCoreSar": {
"description": "关于对话框标题"
@@ -308,10 +292,6 @@
"@resetPath": {
"description": "重置联系人路径以重新路由的操作"
},
"publicKeyCopied": "公钥已复制到剪贴板",
"@publicKeyCopied": {
"description": "复制公钥后的成功消息"
},
"copiedToClipboard": "{label} 已复制到剪贴板",
"@copiedToClipboard": {
"description": "将值复制到剪贴板后的成功消息",
@@ -720,22 +700,6 @@
"@lastSeen": {
"description": "最后在线标签"
},
"roomStatus": "房间状态",
"@roomStatus": {
"description": "房间状态区域标题"
},
"loginStatus": "登录状态",
"@loginStatus": {
"description": "登录状态标签"
},
"notLoggedIn": "未登录",
"@notLoggedIn": {
"description": "未登录状态"
},
"adminAccess": "管理员权限",
"@adminAccess": {
"description": "管理员权限标签"
},
"yes": "是",
"@yes": {
"description": "肯定回答"
@@ -748,14 +712,6 @@
"@permissions": {
"description": "权限标签"
},
"passwordSaved": "密码已保存",
"@passwordSaved": {
"description": "密码已保存标签"
},
"locationColon": "位置:",
"@locationColon": {
"description": "位置区域标题"
},
"telemetry": "遥测",
"@telemetry": {
"description": "遥测区域标题"
@@ -784,19 +740,6 @@
"@gpsTelemetry": {
"description": "来自遥测的 GPS 标签"
},
"updated": "更新于",
"@updated": {
"description": "更新时间戳标签"
},
"pathResetInfo": "已重置 {name} 的路径。下一条消息将寻找新路由。",
"@pathResetInfo": {
"description": "路径重置后的信息消息",
"placeholders": {
"name": {
"type": "String"
}
}
},
"reLoginToRoom": "重新登录到房间",
"@reLoginToRoom": {
"description": "重新登录到房间的按钮"
@@ -1158,10 +1101,6 @@
"@frequencyMHz": {
"description": "无线电频率字段标签"
},
"frequencyExample": "例如 869.618",
"@frequencyExample": {
"description": "频率示例帮助文本"
},
"bandwidth": "带宽",
"@bandwidth": {
"description": "带宽下拉菜单标签"
@@ -1265,47 +1204,6 @@
"@trailControls": {
"description": "轨迹控制工具提示"
},
"contactTrails": "联系人轨迹",
"@contactTrails": {
"description": "联系人轨迹区域标题"
},
"showAllContactTrails": "显示所有联系人轨迹",
"@showAllContactTrails": {
"description": "显示所有联系人轨迹的开关标签"
},
"noContactsWithLocationHistory": "没有带位置历史的联系人",
"@noContactsWithLocationHistory": {
"description": "当没有联系人拥有轨迹时的副标题"
},
"showingTrailsForContacts": "正在显示 {count} 个联系人的轨迹",
"@showingTrailsForContacts": {
"description": "显示拥有轨迹的联系人数量的副标题",
"placeholders": {
"count": {
"type": "int"
}
}
},
"individualContactTrails": "单个联系人轨迹",
"@individualContactTrails": {
"description": "单个联系人轨迹的扩展磁贴标题"
},
"deviceInformation": "设备信息",
"@deviceInformation": {
"description": "设备信息区域标题"
},
"bleName": "BLE 名称",
"@bleName": {
"description": "低功耗蓝牙设备名称标签"
},
"meshName": "Mesh 名称",
"@meshName": {
"description": "Mesh 网络名称标签"
},
"notSet": "未设置",
"@notSet": {
"description": "当值未设置时的标签"
},
"model": "型号",
"@model": {
"description": "设备型号标签"
@@ -1314,14 +1212,6 @@
"@version": {
"description": "版本标签"
},
"buildDate": "构建日期",
"@buildDate": {
"description": "固件构建日期标签"
},
"firmware": "固件",
"@firmware": {
"description": "固件标签"
},
"maxContacts": "最大联系人数量",
"@maxContacts": {
"description": "最大联系人容量标签"
@@ -1334,26 +1224,6 @@
"@publicInfo": {
"description": "公共信息区域标题"
},
"meshNetworkName": "Mesh 网络名称",
"@meshNetworkName": {
"description": "Mesh 网络名称字段标签"
},
"nameBroadcastInMesh": "在 Mesh 广播中公布的名称",
"@nameBroadcastInMesh": {
"description": "Mesh 网络名称字段的帮助文本"
},
"telemetryAndLocationSharing": "遥测与位置共享",
"@telemetryAndLocationSharing": {
"description": "遥测和位置共享开关标签"
},
"lat": "纬度",
"@lat": {
"description": "纬度字段标签(短格式)"
},
"lon": "经度",
"@lon": {
"description": "经度字段标签(短格式)"
},
"useCurrentLocation": "使用当前位置",
"@useCurrentLocation": {
"description": "使用当前位置按钮的工具提示"
@@ -1383,15 +1253,6 @@
}
}
},
"copiedToClipboardShort": "{label} 已复制到剪贴板",
"@copiedToClipboardShort": {
"description": "复制到剪贴板的简短成功消息",
"placeholders": {
"label": {
"type": "String"
}
}
},
"failedToSave": "保存失败:{error}",
"@failedToSave": {
"description": "保存失败的通用错误消息",
@@ -1414,10 +1275,6 @@
"@sarTemplates": {
"description": "搜救模板菜单标题"
},
"manageSarTemplates": "管理光标目标模板",
"@manageSarTemplates": {
"description": "搜救模板设置的副标题"
},
"addTemplate": "添加模板",
"@addTemplate": {
"description": "添加新搜救模板的按钮"
@@ -1541,10 +1398,6 @@
"@ok": {
"description": "确定按钮标签"
},
"permissionsSection": "权限",
"@permissionsSection": {
"description": "权限区域标题"
},
"locationPermission": "位置权限",
"@locationPermission": {
"description": "位置权限标签"
@@ -2109,94 +1962,6 @@
"@wizardWelcomeTitle": {
"description": "欢迎向导第一页标题"
},
"wizardWelcomeDescription": "一个功能强大的离网通信工具,用于搜索和救援行动。当传统网络不可用时,使用 Mesh 无线电技术与您的团队保持联系。",
"@wizardWelcomeDescription": {
"description": "欢迎向导第一页描述"
},
"wizardConnectingTitle": "连接到您的无线电",
"@wizardConnectingTitle": {
"description": "向导连接页面标题"
},
"wizardConnectingDescription": "通过蓝牙将您的智能手机连接到 MeshCore 无线电设备,开始离网通信。",
"@wizardConnectingDescription": {
"description": "向导连接页面描述"
},
"wizardConnectingFeature1": "扫描附近的 MeshCore 设备",
"@wizardConnectingFeature1": {
"description": "向导连接功能 1"
},
"wizardConnectingFeature2": "通过蓝牙与您的无线电配对",
"@wizardConnectingFeature2": {
"description": "向导连接功能 2"
},
"wizardConnectingFeature3": "完全离线工作 - 无需互联网",
"@wizardConnectingFeature3": {
"description": "向导连接功能 3"
},
"wizardChannelTitle": "频道",
"@wizardChannelTitle": {
"description": "向导频道页面标题"
},
"wizardChannelDescription": "向频道上的所有人广播消息,非常适合全队通告和协调。",
"@wizardChannelDescription": {
"description": "向导频道页面描述"
},
"wizardChannelFeature1": "用于一般团队通信的公共频道",
"@wizardChannelFeature1": {
"description": "向导频道功能 1"
},
"wizardChannelFeature2": "为特定小组创建自定义频道",
"@wizardChannelFeature2": {
"description": "向导频道功能 2"
},
"wizardChannelFeature3": "消息由 Mesh 网络自动转发",
"@wizardChannelFeature3": {
"description": "向导频道功能 3"
},
"wizardContactsTitle": "联系人",
"@wizardContactsTitle": {
"description": "向导联系人页面标题"
},
"wizardContactsDescription": "您的团队成员在加入 Mesh 网络时会自动出现。向他们发送直连消息或查看他们的位置。",
"@wizardContactsDescription": {
"description": "向导联系人页面描述"
},
"wizardContactsFeature1": "联系人自动发现",
"@wizardContactsFeature1": {
"description": "向导联系人功能 1"
},
"wizardContactsFeature2": "发送私密的直连消息",
"@wizardContactsFeature2": {
"description": "向导联系人功能 2"
},
"wizardContactsFeature3": "查看电池电量和最后在线时间",
"@wizardContactsFeature3": {
"description": "向导联系人功能 3"
},
"wizardMapTitle": "地图与位置",
"@wizardMapTitle": {
"description": "向导地图页面标题"
},
"wizardMapDescription": "实时跟踪您的团队,并为搜救行动标记重要位置。",
"@wizardMapDescription": {
"description": "向导地图页面描述"
},
"wizardMapFeature1": "用于标记找到的人员、火灾和集结区的搜救标记",
"@wizardMapFeature1": {
"description": "向导地图功能 1"
},
"wizardMapFeature2": "团队成员的实时 GPS 跟踪",
"@wizardMapFeature2": {
"description": "向导地图功能 2"
},
"wizardMapFeature3": "为偏远地区下载离线地图",
"@wizardMapFeature3": {
"description": "向导地图功能 3"
},
"wizardMapFeature4": "绘制形状并共享战术信息",
"@wizardMapFeature4": {
"description": "向导地图功能 4"
},
"viewWelcomeTutorial": "查看欢迎教程",
"@viewWelcomeTutorial": {
"description": "设置中重新显示欢迎向导的选项"
@@ -2352,29 +2117,10 @@
"posttxDelay": "发送后延迟",
"bandpass": "带通",
"bandpassFilterVoice": "带通语音滤波",
"frequency": "频率",
"australia": "澳大利亚",
"australiaNarrow": "澳大利亚(窄带)",
"australiaQld": "澳大利亚QLD",
"australiaSaWa": "澳大利亚SA, WA",
"newZealand": "新西兰",
"newZealandNarrow": "新西兰(窄带)",
"switzerland": "瑞士",
"portugal433": "葡萄牙 433",
"portugal868": "葡萄牙 868",
"czechRepublicNarrow": "捷克(窄带)",
"eu433mhzLongRange": "欧盟 433MHz远距离",
"euukDeprecated": "欧盟/英国(已弃用)",
"euukNarrow": "欧盟/英国(窄带)",
"usacanadaRecommended": "美国/加拿大(推荐)",
"vietnamDeprecated": "越南(已弃用)",
"vietnamNarrow": "越南(窄带)",
"active": "活动",
"addContact": "添加联系人",
"all": "全部",
"autoResolve": "自动解析",
"clearAllLabel": "清除全部",
"clearRelays": "清除中继",
"clearFilters": "清除过滤器",
"clearRoute": "清除路由",
"clearMessages": "清除消息",
@@ -2400,10 +2146,7 @@
"send": "发送",
"sendAnyway": "仍然发送",
"share": "分享",
"shareContact": "分享联系人",
"trace": "追踪",
"use": "使用",
"useSelectedFrequency": "使用选定频率",
"discovery": "发现",
"discoverRepeaters": "发现中继器",
"discoverSensors": "发现传感器",
@@ -2426,7 +2169,6 @@
"gpsModule": "GPS 模块",
"liveTraffic": "实时流量",
"repeatersMap": "中继器地图",
"spectrumScan": "频谱扫描",
"blePacketLogs": "BLE 数据包日志",
"onlineTraceDatabase": "在线追踪数据库",
"routePathByteSize": "路由路径字节大小",
@@ -2438,7 +2180,6 @@
"disableContacts": "禁用联系人",
"enableSensorsTab": "启用传感器标签",
"enableProfiles": "启用配置文件",
"autoRouteRotation": "自动路由轮换",
"nearestRepeaterFallback": "最近中继器回退",
"deleteAllStoredMessageHistory": "删除所有存储的消息历史",
"messageFontSize": "消息字体大小",
@@ -2446,18 +2187,13 @@
"showMapDebugInfo": "显示地图调试信息",
"openMapInFullscreen": "全屏打开地图",
"showSarMarkersLabel": "显示 SAR 标记",
"displaySarMarkersOnTheMainMap": "在主地图上显示 SAR 标记",
"showAllContactTrailsLabel": "显示所有联系人轨迹",
"hideRepeatersOnMap": "在地图上隐藏中继器",
"setMapScale": "设置地图比例",
"customMapScaleSaved": "自定义地图比例已保存",
"voiceBitrate": "语音比特率",
"voiceCompressor": "语音压缩器",
"balancesQuietAndLoudSpeechLevels": "平衡安静和响亮的语音级别",
"voiceLimiter": "语音限制器",
"preventsClippingPeaksBeforeEncoding": "防止编码前的削波",
"micAutoGain": "麦克风自动增益",
"letsTheRecorderAdjustInputLevel": "让录音器调整输入级别",
"echoCancellation": "回声消除",
"noiseSuppression": "噪音抑制",
"trimSilenceInVoiceMessages": "修剪语音消息中的静音",
@@ -2499,13 +2235,10 @@
"staging": "集结区",
"object": "物体",
"quiet": "安静",
"moderate": "中等",
"busy": "繁忙",
"spectrumScanReturnedNoCandidateFrequencies": "频谱扫描未找到候选频率",
"searchMessages": "搜索消息",
"sendImageFromGallery": "从图库发送图片",
"takePhoto": "拍照",
"dmOnly": "仅私信",
"allMessages": "所有消息",
"sendToPublicChannel": "发送到公共频道?",
"selectMarkerTypeAndDestination": "选择标记类型和目标",
@@ -2524,8 +2257,6 @@
"wipeDevice": "擦除设备",
"destructiveDeviceActions": "破坏性设备操作。",
"chooseAPresetOrFinetuneCustomRadioSettings": "选择预设或微调自定义无线电设置。",
"chooseTheNameAndLocationThisDeviceShares": "选择此设备共享的名称和位置。",
"availableSpaceOnThisDevice": "此设备上的可用空间。",
"used": "已使用",
"total": "总计",
"renameValue": "重命名值",
@@ -2543,8 +2274,6 @@
"onlineOnly": "仅在线",
"inBoth": "两者中",
"source": "来源",
"manualRouteEdit": "手动路由编辑",
"observedMeshRoute": "观察到的网格路由",
"allMessagesCleared": "所有消息已清除",
"onlineTraceDatabaseCleared": "在线追踪数据库已清除",
"packetLogsCleared": "数据包日志已清除",
@@ -2575,5 +2304,194 @@
"captured": "已捕获",
"size": "大小",
"noCustomChannelsToClear": "没有自定义频道可清除。",
"noDeviceContactsToClear": "没有设备联系人可清除。"
"noDeviceContactsToClear": "没有设备联系人可清除。",
"autoSend": "自动发送",
"autoSendDescription": "通过当前路径发送。",
"sendDirect": "直接发送",
"sendDirectDescription": "直接发送给此联系人。",
"sendFlood": "泛洪发送",
"sendFloodDescription": "通过所有中继器发送。",
"postConnectDiscoveryTitle": "现在发现中继器?",
"postConnectDiscoveryDescription": "连接后立即运行中继器发现,以查看附近的 MeshCore 节点并更快地将它们添加到您的网络中。",
"aToZ": "A-Z",
"add": "添加",
"addAdditionalDetails": "添加其他详情",
"addPeer": "添加节点",
"addPeerManually": "手动添加节点",
"addedToContacts": "{name} 已添加到联系人",
"addedToSensors": "{name} 已添加到传感器",
"allowAll": "全部允许",
"anonymousRxStats": "匿名接收统计",
"appearance": "外观",
"ascii": "ASCII",
"baseTelemetry": "基础遥测",
"blePin": "BLE PIN",
"buzzerAlerts": "蜂鸣器警报",
"cancelImageReceive": "取消接收图片",
"choose": "选择",
"chooseWhichTabsAndContactSectionsToShow": "选择要显示的标签页和联系人分区",
"clearCache": "清除缓存",
"clearHistory": "清除历史",
"clearLiveView": "清除实时视图",
"clearLogs": "清除日志",
"clearOfflineCache": "清除离线缓存?",
"clearOverlay": "清除叠加层",
"clearRouteOnlyAfterAllRetriesFail": "仅在所有重试失败后清除路由",
"codecBitrateAndAudioProcessing": "编解码器、比特率和音频处理",
"connectByIpAddress": "通过 IP 地址连接",
"controlWhichAlertsYouReceive": "控制接收哪些警报",
"copyFullHex": "复制完整 Hex",
"copyRawResponse": "复制原始响应",
"couldNotLoadMetHistory": "无法加载 MET 历史",
"customizeSensor": "自定义 {name}",
"data": "数据",
"deleteGroup": "删除群组",
"deleteStyleConfirm": "删除 {name}",
"deleteThisDrawing": "删除此{type}",
"deny": "拒绝",
"deviceInfo": "设备信息",
"deviceSettings": "设备设置",
"deviceTimeSynced": "设备时间已同步到此手机。",
"disableFastGpsPublishing": "禁用快速 GPS 发布",
"displayMarkersAndTraceDatabase": "显示、标记和轨迹数据库",
"download": "下载",
"environmentalTelemetry": "环境遥测",
"errorCheckingForUpdates": "检查更新出错:{error}",
"errorGeneric": "错误:{error}",
"errorOpeningDownload": "打开下载出错:{error}",
"exportLogs": "导出日志",
"extraAggressiveAvifCompression": "超强 AVIF 压缩",
"failedToAddContact": "添加联系人失败:{error}",
"failedToAddName": "添加 {name} 失败:{error}",
"failedToClearChannels": "清除频道失败:{error}",
"failedToClearContacts": "清除联系人失败:{error}",
"failedToConnectViaSerial": "通过串口连接失败",
"failedToLoadPreviewImage": "加载预览图片失败:{error}",
"failedToLoadTrace": "加载轨迹失败:{error}",
"failedToSendSarMarker": "发送 SAR 标记失败:{error}",
"failedToSetBuzzerMode": "设置蜂鸣器模式失败:{error}",
"failedToSetGpsMode": "设置 GPS 模式失败:{error}",
"failedToSyncDeviceTime": "同步设备时间失败:{error}",
"failedToWipeDeviceData": "擦除设备数据失败:{error}",
"fastGpsTargetChannel": "快速 GPS 目标频道",
"fastUpdatesThresholdsAndPermissions": "快速更新、阈值和权限",
"fetchingCatalog": "正在获取目录…",
"fifteenMinutes": "15 分钟",
"fifteenSeconds": "15 秒",
"filterByDirection": "按方向筛选",
"finish": "完成",
"fiveMinutes": "5 分钟",
"fiveSeconds": "5 秒",
"fix": "定位",
"gps": "GPS",
"gpsAdvertPolicy": "GPS 广播策略",
"gpsAndLocation": "GPS 与位置",
"gpsDiagnostics": "GPS 诊断",
"gpsSection": "GPS",
"help": "帮助",
"hex": "Hex",
"hidden": "已隐藏",
"howTheRadioAutoAddsDiscoveredNodes": "无线电自动添加已发现节点的方式。",
"images": "图片",
"importProfile": "导入配置文件",
"lastFix": "最后定位",
"latest": "最新",
"latitude": "纬度",
"loadASavedRegion": "加载已保存的区域",
"loading": "加载中",
"locationSharingHardwareAndUpdateInterval": "位置共享、硬件和更新间隔。",
"locationTelemetry": "位置遥测",
"lockDestination": "锁定目的地",
"longitude": "经度",
"mapStyle": "地图样式",
"max": "最大",
"maxZoom": "最大缩放",
"messaging": "消息传递",
"metHistory": "MET 历史",
"min": "最小",
"minZoom": "最小缩放",
"moveDown": "下移",
"moveUp": "上移",
"multiAckMode": "多重 ACK 模式",
"multiDeviceWorkspaceManagement": "多设备工作区管理",
"myLocation": "我的位置",
"nameAndTelemetryShared": "名称和遥测数据已与其他设备共享。",
"newVersionAvailable": "新版本:{version}",
"newer": "较新",
"noNeighboursFound": "未找到邻近节点",
"noServersFound": "未找到服务器",
"notSet": "未设置",
"notifications": "通知",
"offZeroSeconds": "关闭 (0 秒)",
"offlineMaps": "离线地图",
"older": "较旧",
"oneByteMode0": "1 字节(模式 0",
"oneHour": "1 小时",
"oneMinute": "1 分钟",
"openPacketLogs": "打开数据包日志",
"packetTypeHelp": "数据包类型帮助",
"pasteShareLinkOrHexAdvert": "粘贴分享链接或 Hex 广播",
"pathHash": "路径哈希",
"pathSize": "路径大小",
"ping": "Ping",
"polygon": "多边形",
"rawResponseCopied": "原始响应已复制",
"resendThroughNearestRepeaterOnFailure": "失败时通过最近的中继器重新发送",
"resolveContact": "解析联系人",
"routingRetriesAndDestinationLock": "路由、重试和目的地锁定",
"samples": "采样",
"satellites": "卫星",
"saving": "保存中…",
"search": "搜索",
"searchByNameOrToken": "按名称或令牌搜索",
"searchDiscoveredNodes": "搜索已发现的节点",
"searchInCurrentFilter": "在当前筛选中搜索",
"searchLogs": "搜索日志…",
"searchSensors": "搜索传感器",
"searchThisSection": "搜索此分区",
"selectedRelays": "已选中继器",
"sensor": "传感器",
"serialError": "串口错误:{error}",
"setPathForContact": "为 {name} 设置路径",
"shareLiveGps": "共享实时 GPS",
"shareMyTiles": "共享我的地图瓦片",
"sharingLocationFromPhone": "正在从手机共享此频道的位置。",
"sharingLocationFromRadio": "正在从无线电共享此频道的位置。",
"signalDbm": "信号 {rssi} dBm",
"sizeCompressionAndPreview": "大小、压缩和预览",
"skip": "跳过",
"smallerFileSize": "更小的文件大小",
"sort": "排序",
"stoppedSharingLocation": "已停止在此频道上共享位置。",
"suppressNotificationsWhileInForeground": "前台运行时抑制通知",
"tabsAndNavigation": "标签页与导航",
"templatesAndTutorials": "模板和教程",
"tenMinutes": "10 分钟",
"tenSeconds": "10 秒",
"testSendUpdate": "测试发送更新",
"themeLanguageAndDisplayPreferences": "主题、语言和显示偏好",
"thirtyMinutes": "30 分钟",
"thirtySeconds": "30 秒",
"threeBytesMode2": "3 字节(模式 2",
"trafficStatsMessageHistoryAndDeveloperTools": "流量统计、消息历史和开发者工具",
"twoBytesMode1": "2 字节(模式 1",
"twoMinutes": "2 分钟",
"undo": "撤销",
"updated": "已更新",
"useContactFlags": "使用联系人标志",
"useSavedCoordinates": "使用已保存的坐标",
"view": "查看",
"viewNeighbours": "查看邻近节点",
"viewPublicStats": "查看公开统计",
"setRegionScope": "设置区域范围",
"regionScope": "区域范围",
"regionScopeNone": "无(全局)",
"clearRegionScope": "清除范围",
"regionScopeWarning": "只有允许此区域的中继器会转发。",
"discoverRegions": "从中继器发现",
"discoveringRegions": "正在发现区域...",
"enterRegionName": "区域名称(例如 auckland",
"noRegionsFound": "此中继器上未找到区域。",
"regionScopeSet": "区域范围已设置为 {name}",
"regionScopeCleared": "区域范围已清除"
}

View File

@@ -18,6 +18,7 @@ import 'providers/channels_provider.dart';
import 'providers/voice_provider.dart';
import 'providers/image_provider.dart' as ip;
import 'providers/app_provider.dart';
import 'providers/offline_tiles_provider.dart';
import 'providers/sensors_provider.dart';
import 'services/voice_codec_service.dart';
import 'services/voice_player_service.dart';
@@ -285,6 +286,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
),
ChangeNotifierProvider(create: (_) => ChannelsProvider()),
ChangeNotifierProvider(create: (_) => SensorsProvider()),
ChangeNotifierProvider(create: (_) => OfflineTilesProvider()),
ChangeNotifierProvider(
create: (_) {
final manager = ProfileManager();
@@ -372,6 +374,8 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
mapProvider: mapProvider,
drawingProvider: context.read<DrawingProvider>(),
channelsProvider: context.read<ChannelsProvider>(),
voiceProvider: context.read<VoiceProvider>(),
imageProvider: context.read<ip.ImageProvider>(),
appProvider: appProvider,
),
),
@@ -389,7 +393,7 @@ class _MeshCoreSarAppState extends State<MeshCoreSarApp> {
key: ValueKey<String?>(
'${_locale?.languageCode ?? 'system'}_${_themeMode.name}',
),
title: 'MeshCore SAR',
onGenerateTitle: (context) => AppLocalizations.of(context)?.appTitle ?? 'MeshCore SAR',
debugShowCheckedModeBanner: false,
theme: AppTheme.getTheme(_themeMode, systemBrightness),
locale: _locale,

View File

@@ -57,9 +57,7 @@ class Channel {
} else {
// Normal channel: require explicit secret
if (explicitSecret == null || explicitSecret.length != 16) {
throw ArgumentError(
'Normal channels require a 16-byte secret',
);
throw ArgumentError('Normal channels require a 16-byte secret');
}
return Channel(
index: index,
@@ -78,6 +76,18 @@ class Channel {
return Uint8List.fromList(digest.bytes.sublist(0, 16));
}
static bool isHashChannelName(String channelName) {
return channelName.trim().startsWith('#');
}
static String pskBase64ForHashChannelName(String channelName) {
final normalized = channelName.trim();
if (!isHashChannelName(normalized)) {
throw ArgumentError('Only #channels can export derived psk_base64');
}
return base64.encode(_generateHashChannelSecret(normalized));
}
/// Create the default public channel (channel 0)
/// Uses the well-known pre-shared key from MeshCore
factory Channel.publicChannel() {
@@ -85,8 +95,22 @@ class Channel {
index: 0,
name: 'Public Channel',
secret: Uint8List.fromList([
0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a,
0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72,
0x8b,
0x33,
0x87,
0xe9,
0xc5,
0xcd,
0xea,
0x6a,
0xc9,
0xe5,
0xed,
0xba,
0xa1,
0x15,
0xcd,
0x72,
]),
flags: null,
);
@@ -95,6 +119,18 @@ class Channel {
/// Check if this is a hash-based channel (name starts with '#')
bool get isHashChannel => name.startsWith('#');
/// Base64-encoded PSK for sharing with firmware CLI and related tooling.
String get pskBase64 => base64.encode(secret);
/// MeshCore group packets carry the first byte of SHA256(channel secret).
int get hashByte {
final digest = sha256.convert(secret);
return digest.bytes.first;
}
String get hashHex =>
hashByte.toRadixString(16).padLeft(2, '0').toUpperCase();
/// Display name for the channel
/// Returns "Public" for channel 0, otherwise returns the custom name or "Channel N"
String get displayName {
@@ -131,12 +167,7 @@ class Channel {
}
/// Create a copy with modified fields
Channel copyWith({
int? index,
String? name,
Uint8List? secret,
int? flags,
}) {
Channel copyWith({int? index, String? name, Uint8List? secret, int? flags}) {
return Channel(
index: index ?? this.index,
name: name ?? this.name,

View File

@@ -116,7 +116,6 @@ class AppSettingsProfileSection {
final bool? voiceEchoCancellationEnabled;
final bool? voiceNoiseSuppressionEnabled;
final double? messageFontScale;
final bool? autoRouteRotationEnabled;
final bool? clearPathOnMaxRetry;
final bool? nearestRelayFallbackEnabled;
final int? voiceBitrate;
@@ -142,7 +141,6 @@ class AppSettingsProfileSection {
this.voiceEchoCancellationEnabled,
this.voiceNoiseSuppressionEnabled,
this.messageFontScale,
this.autoRouteRotationEnabled,
this.clearPathOnMaxRetry,
this.nearestRelayFallbackEnabled,
this.voiceBitrate,
@@ -169,7 +167,6 @@ class AppSettingsProfileSection {
voiceEchoCancellationEnabled == null &&
voiceNoiseSuppressionEnabled == null &&
messageFontScale == null &&
autoRouteRotationEnabled == null &&
clearPathOnMaxRetry == null &&
nearestRelayFallbackEnabled == null &&
voiceBitrate == null &&
@@ -195,7 +192,6 @@ class AppSettingsProfileSection {
'voiceEchoCancellationEnabled': voiceEchoCancellationEnabled,
'voiceNoiseSuppressionEnabled': voiceNoiseSuppressionEnabled,
'messageFontScale': messageFontScale,
'autoRouteRotationEnabled': autoRouteRotationEnabled,
'clearPathOnMaxRetry': clearPathOnMaxRetry,
'nearestRelayFallbackEnabled': nearestRelayFallbackEnabled,
'voiceBitrate': voiceBitrate,
@@ -225,7 +221,6 @@ class AppSettingsProfileSection {
voiceNoiseSuppressionEnabled:
json['voiceNoiseSuppressionEnabled'] as bool?,
messageFontScale: (json['messageFontScale'] as num?)?.toDouble(),
autoRouteRotationEnabled: json['autoRouteRotationEnabled'] as bool?,
clearPathOnMaxRetry: json['clearPathOnMaxRetry'] as bool?,
nearestRelayFallbackEnabled: json['nearestRelayFallbackEnabled'] as bool?,
voiceBitrate: json['voiceBitrate'] as int?,
@@ -263,7 +258,6 @@ class MapWorkspaceProfileSection {
final bool? showKrasFireZonesOverlay;
final bool? showPlaceNamesOverlay;
final bool? showMunicipalityBordersOverlay;
final bool? showAllContactTrails;
final bool? hideRepeatersOnMap;
const MapWorkspaceProfileSection({
@@ -284,7 +278,6 @@ class MapWorkspaceProfileSection {
this.showKrasFireZonesOverlay,
this.showPlaceNamesOverlay,
this.showMunicipalityBordersOverlay,
this.showAllContactTrails,
this.hideRepeatersOnMap,
});
@@ -306,7 +299,6 @@ class MapWorkspaceProfileSection {
showKrasFireZonesOverlay == null &&
showPlaceNamesOverlay == null &&
showMunicipalityBordersOverlay == null &&
showAllContactTrails == null &&
hideRepeatersOnMap == null;
Map<String, dynamic> toJson() => {
@@ -327,7 +319,6 @@ class MapWorkspaceProfileSection {
'showKrasFireZonesOverlay': showKrasFireZonesOverlay,
'showPlaceNamesOverlay': showPlaceNamesOverlay,
'showMunicipalityBordersOverlay': showMunicipalityBordersOverlay,
'showAllContactTrails': showAllContactTrails,
'hideRepeatersOnMap': hideRepeatersOnMap,
};
@@ -356,7 +347,6 @@ class MapWorkspaceProfileSection {
showPlaceNamesOverlay: json['showPlaceNamesOverlay'] as bool?,
showMunicipalityBordersOverlay:
json['showMunicipalityBordersOverlay'] as bool?,
showAllContactTrails: json['showAllContactTrails'] as bool?,
hideRepeatersOnMap: json['hideRepeatersOnMap'] as bool?,
);
}

View File

@@ -29,7 +29,7 @@ extension ConnectionModeExtension on ConnectionMode {
case ConnectionMode.tcp:
return 'Direct (WiFi)';
case ConnectionMode.usb:
return 'Direct (USB)';
return 'Direct (Serial)';
}
}
@@ -40,7 +40,7 @@ extension ConnectionModeExtension on ConnectionMode {
case ConnectionMode.tcp:
return 'Direct WiFi/TCP connection to MeshCore device';
case ConnectionMode.usb:
return 'USB serial connection to MeshCore device';
return 'Direct serial connection to MeshCore device';
}
}
}
@@ -71,6 +71,7 @@ class DeviceInfo {
final bool? autoAddRoomServers;
final bool? autoAddSensors;
final bool? autoAddOverwriteOldest;
final int? autoAddMaxHops;
final int? radioFreq;
final int? radioBw;
final int? radioSf;
@@ -95,6 +96,7 @@ class DeviceInfo {
// Repeat mode (firmware v9+)
final bool? clientRepeat;
final int? pathHashMode;
final bool? supportsSpectrumScan;
final int? spectrumScanMinKhz;
final int? spectrumScanMaxKhz;
@@ -123,6 +125,7 @@ class DeviceInfo {
this.autoAddRoomServers,
this.autoAddSensors,
this.autoAddOverwriteOldest,
this.autoAddMaxHops,
this.radioFreq,
this.radioBw,
this.radioSf,
@@ -139,6 +142,7 @@ class DeviceInfo {
this.manufacturerModel,
this.semanticVersion,
this.clientRepeat,
this.pathHashMode,
this.supportsSpectrumScan,
this.spectrumScanMinKhz,
this.spectrumScanMaxKhz,
@@ -254,6 +258,7 @@ class DeviceInfo {
bool? autoAddRoomServers,
bool? autoAddSensors,
bool? autoAddOverwriteOldest,
int? autoAddMaxHops,
int? radioFreq,
int? radioBw,
int? radioSf,
@@ -270,6 +275,7 @@ class DeviceInfo {
String? manufacturerModel,
String? semanticVersion,
bool? clientRepeat,
int? pathHashMode,
bool? supportsSpectrumScan,
int? spectrumScanMinKhz,
int? spectrumScanMaxKhz,
@@ -299,6 +305,7 @@ class DeviceInfo {
autoAddSensors: autoAddSensors ?? this.autoAddSensors,
autoAddOverwriteOldest:
autoAddOverwriteOldest ?? this.autoAddOverwriteOldest,
autoAddMaxHops: autoAddMaxHops ?? this.autoAddMaxHops,
radioFreq: radioFreq ?? this.radioFreq,
radioBw: radioBw ?? this.radioBw,
radioSf: radioSf ?? this.radioSf,
@@ -315,6 +322,7 @@ class DeviceInfo {
manufacturerModel: manufacturerModel ?? this.manufacturerModel,
semanticVersion: semanticVersion ?? this.semanticVersion,
clientRepeat: clientRepeat ?? this.clientRepeat,
pathHashMode: pathHashMode ?? this.pathHashMode,
supportsSpectrumScan: supportsSpectrumScan ?? this.supportsSpectrumScan,
spectrumScanMinKhz: spectrumScanMinKhz ?? this.spectrumScanMinKhz,
spectrumScanMaxKhz: spectrumScanMaxKhz ?? this.spectrumScanMaxKhz,

View File

@@ -15,10 +15,14 @@ class MessageContactLocation {
String get technicalSourceLabel {
switch (source) {
case 'shared':
return 'shared location';
case 'gps':
return 'shared location';
case 'telemetry':
return 'telemetry';
return 'live telemetry';
case 'advert':
return 'advert';
return 'shared advert';
default:
return source;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,8 @@ class ChannelsProvider with ChangeNotifier {
int _selectedChannelIndex = 0; // Default to public channel
/// Get all channels
List<Channel> get channels => _channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
List<Channel> get channels =>
_channels.values.toList()..sort((a, b) => a.index.compareTo(b.index));
/// Get a specific channel by index
Channel? getChannel(int index) => _channels[index];
@@ -28,6 +29,16 @@ class ChannelsProvider with ChangeNotifier {
return index == 0 ? 'Public' : 'Channel $index';
}
List<Channel> getChannelsByHashByte(int hashByte) {
return _channels.values.where((channel) => channel.hashByte == hashByte).toList()
..sort((a, b) => a.index.compareTo(b.index));
}
String? getUniqueChannelDisplayNameByHashByte(int hashByte) {
final matches = getChannelsByHashByte(hashByte);
return matches.length == 1 ? matches.single.displayName : null;
}
/// Add or update a channel
void addOrUpdateChannel({
required int index,
@@ -54,12 +65,12 @@ class ChannelsProvider with ChangeNotifier {
void removeChannel(int index) {
if (_channels.containsKey(index)) {
_channels.remove(index);
// If the deleted channel was selected, switch to public channel
if (_selectedChannelIndex == index) {
_selectedChannelIndex = 0;
}
notifyListeners();
}
}
@@ -96,6 +107,13 @@ class ChannelsProvider with ChangeNotifier {
notifyListeners();
}
/// Clear runtime channel state before a live device sync begins.
void prepareForDeviceSync() {
_channels.clear();
_selectedChannelIndex = 0;
notifyListeners();
}
/// Check if channels have been loaded
bool get hasChannels => _channels.isNotEmpty;

View File

@@ -47,6 +47,23 @@ class PingResult {
});
}
/// Result of a relay ping (trace path) operation
class RelayPingResult {
final bool success;
final int durationMs;
final double snrThere;
final double snrBack;
final int hopCount;
const RelayPingResult({
required this.success,
required this.durationMs,
required this.snrThere,
required this.snrBack,
required this.hopCount,
});
}
/// Scanned device with RSSI information
class ScannedDevice {
final BluetoothDevice device;
@@ -73,6 +90,7 @@ class ConnectionProvider with ChangeNotifier {
final MeshCoreBleService _bleService = MeshCoreBleService();
MeshCoreTcpService? _tcpService;
MeshCoreSerialService? _serialService;
Future<void> Function()? _serialDisconnectTransport;
/// Expose BLE service for background location tracking
MeshCoreBleService get bleService => _bleService;
@@ -104,8 +122,6 @@ class ConnectionProvider with ChangeNotifier {
bool _isScanning = false;
bool get isScanning => _isScanning;
bool _isSpectrumScanActive = false;
bool get isSpectrumScanActive => _isSpectrumScanActive;
final Set<int> _pendingDeletedChannelIndices = <int>{};
String? _error;
@@ -173,6 +189,8 @@ class ConnectionProvider with ChangeNotifier {
MessageDeliveryTracker();
final PingTracker _pingTracker = PingTracker();
final Map<String, Future<PingResult>> _pendingSmartPings = {};
final Map<int, Completer<RelayPingResult>> _pendingRelayPings = {};
final Map<int, int> _relayPingStartTimes = {};
// Expose room login states
Map<String, RoomLoginState> get roomLoginStates =>
@@ -211,6 +229,15 @@ class ConnectionProvider with ChangeNotifier {
onMessageEchoDetected;
Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse;
Function(Uint8List payload, int snrRaw, int rssiDbm)? onRawDataReceived;
Function(
int channelIdx,
int pathLen,
int dataType,
Uint8List payload,
int snrRaw,
int? rssiDbm,
)?
onChannelDataReceived;
Function(Uint8List payload, int snrRaw, int rssiDbm, int pathLen)?
onControlDataReceived;
Contact? Function(Uint8List contactPublicKey)? resolveContactForDmCallback;
@@ -246,7 +273,7 @@ class ConnectionProvider with ChangeNotifier {
}
if (activeMode != ConnectionMode.usb && _serialService != null) {
_disposeSerialService();
await _disposeSerialService();
}
}
@@ -260,10 +287,20 @@ class ConnectionProvider with ChangeNotifier {
service.dispose();
}
void _disposeSerialService() {
_serialService?.markDisconnected();
_serialService?.dispose();
Future<void> _disposeSerialService() async {
final service = _serialService;
final disconnectTransport = _serialDisconnectTransport;
_serialService = null;
_serialDisconnectTransport = null;
if (service == null) {
return;
}
service.markDisconnected();
service.writeRaw = null;
if (disconnectTransport != null) {
await disconnectTransport();
}
service.dispose();
}
void _beginConnectionAttempt({
@@ -452,10 +489,6 @@ class ConnectionProvider with ChangeNotifier {
};
service.onMessageWaiting = () {
if (_isSpectrumScanActive) {
debugPrint('📥 [Provider] MSG_WAITING ignored during spectrum scan');
return;
}
if (!(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
@@ -522,6 +555,16 @@ class ConnectionProvider with ChangeNotifier {
service.onRawDataReceived = (payload, snrRaw, rssiDbm) =>
onRawDataReceived?.call(payload, snrRaw, rssiDbm);
service.onChannelDataReceived =
(channelIdx, pathLen, dataType, payload, snrRaw, rssiDbm) =>
onChannelDataReceived?.call(
channelIdx,
pathLen,
dataType,
payload,
snrRaw,
rssiDbm,
);
service.onControlDataReceived = (payload, snrRaw, rssiDbm, pathLen) =>
onControlDataReceived?.call(payload, snrRaw, rssiDbm, pathLen);
@@ -536,10 +579,15 @@ class ConnectionProvider with ChangeNotifier {
manufacturerModel: deviceInfo['manufacturerModel'] as String?,
semanticVersion: deviceInfo['semanticVersion'] as String?,
clientRepeat: deviceInfo['clientRepeat'] as bool?,
supportsSpectrumScan: deviceInfo['supportsSpectrumScan'] as bool?,
spectrumScanMinKhz: deviceInfo['spectrumScanMinKhz'] as int?,
spectrumScanMaxKhz: deviceInfo['spectrumScanMaxKhz'] as int?,
pathHashMode: deviceInfo['pathHashMode'] as int?,
);
// Clear any stale flood scope on connect (firmware v8+)
final fwVer = deviceInfo['firmwareVersion'] as int?;
if (fwVer != null && fwVer >= 8) {
clearFloodScope();
}
notifyListeners();
};
@@ -552,6 +600,9 @@ class ConnectionProvider with ChangeNotifier {
publicKey: selfInfo['publicKey'] as Uint8List?,
advLat: selfInfo['advLat'] as int?,
advLon: selfInfo['advLon'] as int?,
multiAcks: selfInfo['multiAcks'] as int?,
advertLocPolicy: selfInfo['advertLocPolicy'] as int?,
telemetryModes: selfInfo['telemetryModes'] as int?,
manualAddContacts: selfInfo['manualAddContacts'] as bool?,
radioFreq: selfInfo['radioFreq'] as int?,
radioBw: selfInfo['radioBw'] as int?,
@@ -594,10 +645,15 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: config['autoAddRoomServers'] as bool?,
autoAddSensors: config['autoAddSensors'] as bool?,
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
autoAddMaxHops: config['autoAddMaxHops'] as int?,
);
notifyListeners();
};
service.onTraceDataReceived = (nonce, hopCount, snrThere, snrBack) {
_handleTraceDataReceived(nonce, hopCount, snrThere, snrBack);
};
service.onTxActivity = () {
_txActivity = true;
notifyListeners();
@@ -635,6 +691,13 @@ class ConnectionProvider with ChangeNotifier {
debugPrint('✅ [Provider] Scan state initialized, notifying listeners');
try {
final adapterError = await _awaitBleAdapterReady();
if (adapterError != null) {
debugPrint('⚠️ [Provider] BLE adapter not ready: $adapterError');
_error = adapterError;
return;
}
await for (final scanResult in _bleService.scanForDevices(
timeout: const Duration(seconds: 10),
)) {
@@ -676,6 +739,53 @@ class ConnectionProvider with ChangeNotifier {
}
}
Future<String?> _awaitBleAdapterReady({
Duration timeout = const Duration(seconds: 4),
}) async {
if (!await FlutterBluePlus.isSupported) {
return 'Bluetooth is not supported on this device.';
}
var state = await FlutterBluePlus.adapterState.first;
debugPrint('🔵 [Provider] Initial BLE adapter state: $state');
if (state == BluetoothAdapterState.unknown ||
state == BluetoothAdapterState.turningOn) {
debugPrint('⏳ [Provider] Waiting for BLE adapter to finish initializing');
try {
state = await FlutterBluePlus.adapterState
.where(
(candidate) =>
candidate != BluetoothAdapterState.unknown &&
candidate != BluetoothAdapterState.turningOn,
)
.first
.timeout(timeout);
} on TimeoutException {
state = FlutterBluePlus.adapterStateNow;
}
debugPrint('🔵 [Provider] BLE adapter state after wait: $state');
}
if (state == BluetoothAdapterState.on) {
return null;
}
return switch (state) {
BluetoothAdapterState.off =>
'Bluetooth is turned off. Turn it on and try again.',
BluetoothAdapterState.turningOff =>
'Bluetooth is turning off. Turn it back on and try again.',
BluetoothAdapterState.unauthorized =>
'Bluetooth access is not allowed. Check app permissions in System Settings and try again.',
BluetoothAdapterState.unavailable =>
'Bluetooth hardware is unavailable. On macOS, verify Bluetooth access is enabled for this app target.',
BluetoothAdapterState.unknown || BluetoothAdapterState.turningOn =>
'Bluetooth is still initializing. Please try again in a moment.',
BluetoothAdapterState.on => null,
};
}
/// Stop scanning
Future<void> stopScan() async {
await FlutterBluePlus.stopScan();
@@ -762,16 +872,22 @@ class ConnectionProvider with ChangeNotifier {
/// The caller is responsible for opening the serial port and wiring
/// [service.writeRaw] + [service.feedRawBytes] before calling this.
/// After this call succeeds, [service.markConnected()] has already run.
Future<bool> connectSerial(MeshCoreSerialService service) async {
Future<bool> connectSerial({
required MeshCoreSerialService service,
required Future<void> Function() disconnectTransport,
required String deviceId,
required String deviceName,
}) async {
debugPrint('🔌 [Provider] connectSerial()');
await _prepareForConnectionSwitch(ConnectionMode.usb);
_disposeSerialService();
await _disposeSerialService();
_serialService = service;
_serialDisconnectTransport = disconnectTransport;
_wireServiceCallbacks(_serialService!);
_beginConnectionAttempt(
mode: ConnectionMode.usb,
deviceId: 'usb',
deviceName: 'USB Companion',
deviceId: deviceId,
deviceName: deviceName,
);
// markConnected() should already have been called by the transport.
@@ -780,6 +896,7 @@ class ConnectionProvider with ChangeNotifier {
_deviceInfo = _deviceInfo.copyWith(
connectionState: ConnectionState.error,
);
await _disposeSerialService();
notifyListeners();
return false;
}
@@ -788,7 +905,7 @@ class ConnectionProvider with ChangeNotifier {
/// Disconnect from USB serial device.
Future<void> disconnectSerial() async {
_disposeSerialService();
await _disposeSerialService();
_resetConnectionSession();
}
@@ -1647,6 +1764,7 @@ class ConnectionProvider with ChangeNotifier {
required int channelIdx,
required String text,
String? messageId,
Uint8List? floodScopeKey,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -1659,10 +1777,12 @@ class ConnectionProvider with ChangeNotifier {
debugPrint(' Channel: $channelIdx');
debugPrint(' Text: $text');
debugPrint(' MessageID: $messageId');
debugPrint(' FloodScope: ${floodScopeKey != null ? "set (${floodScopeKey.length}B)" : "none"}');
await _activeService.sendChannelMessage(
channelIdx: channelIdx,
text: text,
floodScopeKey: floodScopeKey,
);
debugPrint('✅ [ConnectionProvider] BLE send completed');
@@ -1727,6 +1847,42 @@ class ConnectionProvider with ChangeNotifier {
);
}
/// Clear the flood scope on the device (firmware v8+).
Future<void> clearFloodScope() async {
if (!_activeService.isConnected) return;
try {
await _activeService.clearFloodScope();
} catch (e) {
debugPrint('⚠️ [ConnectionProvider] Failed to clear flood scope: $e');
}
}
Future<void> sendChannelData({
required int channelIdx,
required int dataType,
required Uint8List payload,
Uint8List? floodScopeKey,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.sendChannelData(
channelIdx: channelIdx,
dataType: dataType,
payload: payload,
floodScopeKey: floodScopeKey,
);
} catch (e) {
_error = 'Failed to send channel data: $e';
notifyListeners();
rethrow;
}
}
/// Request telemetry from contact
///
/// COMPATIBILITY NOTE: This method sends CMD_SEND_TELEMETRY_REQ (39).
@@ -1781,11 +1937,13 @@ class ConnectionProvider with ChangeNotifier {
return pendingPing;
}
final future = _runSmartPing(
contactPublicKey: contactPublicKey,
hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
final future = _isSelfPublicKey(contactPublicKey)
? _runSelfTelemetryPing(contactPublicKey)
: _runSmartPing(
contactPublicKey: contactPublicKey,
hasPath: hasPath,
onRetryWithFlooding: onRetryWithFlooding,
);
_pendingSmartPings[pingKey] = future;
notifyListeners();
@@ -1872,10 +2030,117 @@ class ConnectionProvider with ChangeNotifier {
}
}
Future<PingResult> _runSelfTelemetryPing(Uint8List devicePublicKey) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return PingResult(success: false, usedFlooding: false, timedOut: true);
}
try {
final pingFuture = _pingTracker.trackPing(
publicKey: devicePublicKey,
wasDirectAttempt: true,
);
// Firmware treats a 4-byte telemetry request as "self telemetry".
await _activeService.requestTelemetry(Uint8List(0), zeroHop: true);
final gotResponse = await pingFuture;
return PingResult(
success: gotResponse,
usedFlooding: false,
timedOut: !gotResponse,
);
} catch (e) {
_error = 'Failed to request self telemetry: $e';
notifyListeners();
return PingResult(success: false, usedFlooding: false, timedOut: true);
}
}
/// Ping a relay/repeater using trace path (command 36).
/// Returns RTT, SNR there/back, and hop count.
Future<RelayPingResult> pingRelay(Contact contact) async {
if (!_activeService.isConnected) {
return const RelayPingResult(
success: false, durationMs: 0, snrThere: 0, snrBack: 0, hopCount: 0,
);
}
final nonce = Random().nextInt(0xFFFFFFFF);
final completer = Completer<RelayPingResult>();
_pendingRelayPings[nonce] = completer;
_relayPingStartTimes[nonce] = DateTime.now().millisecondsSinceEpoch;
// Timeout after 10 seconds
final timer = Timer(const Duration(seconds: 10), () {
_pendingRelayPings.remove(nonce);
_relayPingStartTimes.remove(nonce);
if (!completer.isCompleted) {
completer.complete(const RelayPingResult(
success: false, durationMs: 0, snrThere: 0, snrBack: 0, hopCount: 0,
));
}
});
try {
// Zero-hop ping: prefixSize=1 sends 1 byte of public key, hopType=0
await _activeService.sendTracePath(
nonce: nonce,
prefixSize: 1,
contactPublicKey: contact.publicKey,
);
final result = await completer.future;
timer.cancel();
return result;
} catch (e) {
timer.cancel();
_pendingRelayPings.remove(nonce);
_relayPingStartTimes.remove(nonce);
return const RelayPingResult(
success: false, durationMs: 0, snrThere: 0, snrBack: 0, hopCount: 0,
);
}
}
void _handleTraceDataReceived(
int nonce, int hopCount, double snrThere, double snrBack,
) {
final completer = _pendingRelayPings.remove(nonce);
final startTime = _relayPingStartTimes.remove(nonce);
if (completer != null && !completer.isCompleted) {
final durationMs = startTime != null
? DateTime.now().millisecondsSinceEpoch - startTime
: 0;
completer.complete(RelayPingResult(
success: true,
durationMs: durationMs,
snrThere: snrThere,
snrBack: snrBack,
hopCount: hopCount,
));
}
}
String _publicKeyToHex(Uint8List publicKey) {
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
bool _isSelfPublicKey(Uint8List publicKey) {
final selfKey = _deviceInfo.publicKey;
if (selfKey == null || selfKey.length != publicKey.length) {
return false;
}
for (var i = 0; i < publicKey.length; i++) {
if (selfKey[i] != publicKey[i]) {
return false;
}
}
return true;
}
/// Send binary request to contact (modern replacement for requestTelemetry)
///
/// Supports multiple request types:
@@ -2116,43 +2381,6 @@ class ConnectionProvider with ChangeNotifier {
}
}
Future<SpectrumScanResult?> scanSpectrum({
required int startFrequencyKhz,
required int stopFrequencyKhz,
required int bandwidthKhz,
required int stepKhz,
required int dwellMs,
required int thresholdDb,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return null;
}
try {
_isSpectrumScanActive = true;
_activeService.setSpectrumScanActive(true);
notifyListeners();
return await _activeService.scanSpectrum(
startFrequencyKhz: startFrequencyKhz,
stopFrequencyKhz: stopFrequencyKhz,
bandwidthKhz: bandwidthKhz,
stepKhz: stepKhz,
dwellMs: dwellMs,
thresholdDb: thresholdDb,
);
} catch (e) {
_error = 'Failed to scan spectrum: $e';
notifyListeners();
return null;
} finally {
_isSpectrumScanActive = false;
_activeService.setSpectrumScanActive(false);
notifyListeners();
}
}
/// Set transmit power
Future<void> setTxPower(int powerDbm) async {
if (!_activeService.isConnected) {
@@ -2220,6 +2448,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: config['autoAddRoomServers'] as bool?,
autoAddSensors: config['autoAddSensors'] as bool?,
autoAddOverwriteOldest: config['autoAddOverwriteOldest'] as bool?,
autoAddMaxHops: config['autoAddMaxHops'] as int?,
);
notifyListeners();
} catch (e) {
@@ -2231,6 +2460,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: null,
autoAddSensors: null,
autoAddOverwriteOldest: null,
autoAddMaxHops: null,
);
notifyListeners();
return;
@@ -2250,6 +2480,7 @@ class ConnectionProvider with ChangeNotifier {
required bool autoAddRoomServers,
required bool autoAddSensors,
required bool overwriteOldest,
int maxHops = 0,
}) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -2264,6 +2495,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors,
overwriteOldest: overwriteOldest,
maxHops: maxHops,
);
_deviceInfo = _deviceInfo.copyWith(
autoAddUsers: autoAddUsers,
@@ -2271,6 +2503,7 @@ class ConnectionProvider with ChangeNotifier {
autoAddRoomServers: autoAddRoomServers,
autoAddSensors: autoAddSensors,
autoAddOverwriteOldest: overwriteOldest,
autoAddMaxHops: maxHops,
);
notifyListeners();
} catch (e) {
@@ -2279,6 +2512,23 @@ class ConnectionProvider with ChangeNotifier {
}
}
Future<void> setPathHashMode(int mode) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
await _activeService.setPathHashMode(mode);
_deviceInfo = _deviceInfo.copyWith(pathHashMode: mode);
notifyListeners();
} catch (e) {
_error = 'Failed to set path hash mode: $e';
notifyListeners();
}
}
/// Export a contact as a meshcore:// share URL.
/// Pass null to export self.
Future<String?> exportContactUrl(Uint8List? publicKey) async {
@@ -2320,7 +2570,6 @@ class ConnectionProvider with ChangeNotifier {
}
Future<void> refreshDeviceInfo() async {
if (_isSpectrumScanActive) return;
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
@@ -2363,6 +2612,18 @@ class ConnectionProvider with ChangeNotifier {
/// - Total storage in KB (if available)
///
/// Results arrive via onBatteryAndStorage callback and update deviceInfo.
/// Request the companion device's own telemetry (GPS, temp, sensors).
/// The response arrives via onTelemetryReceived with the device's own key.
Future<void> requestSelfTelemetry() async {
if (!_activeService.isConnected) return;
try {
// Firmware expects a 4-byte CMD_SEND_TELEMETRY_REQ frame for "self".
await _activeService.requestTelemetry(Uint8List(0), zeroHop: true);
} catch (e) {
debugPrint('⚠️ [Provider] requestSelfTelemetry failed: $e');
}
}
Future<void> getBatteryAndStorage() async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
@@ -2381,7 +2642,6 @@ class ConnectionProvider with ChangeNotifier {
/// Sync messages from device queue
/// Call this repeatedly until no more messages are available
Future<bool> syncNextMessage() async {
if (_isSpectrumScanActive) return false;
// Prevent re-entrancy and too-fast triggers
if (_isSyncingMessages) {
// Another sync (single or loop) is in progress
@@ -2420,10 +2680,6 @@ class ConnectionProvider with ChangeNotifier {
/// Sync all waiting messages from device
Future<int> syncAllMessages({bool force = false}) async {
if (_isSpectrumScanActive) {
debugPrint('⏸️ [Provider] Message sync skipped during spectrum scan');
return 0;
}
if (!force && !(canStartAutomaticMessageSyncCallback?.call() ?? true)) {
_pendingAutomaticMessageSync = true;
debugPrint(
@@ -2711,6 +2967,32 @@ class ConnectionProvider with ChangeNotifier {
}
}
/// Set a contact to zero-hop direct mode (no mesh forwarding).
///
/// This forces the firmware to send directly to this contact without
/// using any repeaters, matching the "Send Direct" option in the
/// official MeshCore client.
Future<void> setContactDirect(Contact contact) async {
if (!_activeService.isConnected) {
_error = 'Not connected to device';
notifyListeners();
return;
}
try {
_error = null;
final updatedContact = contact.copyWith(
outPathLen: 0, // descriptor: hashSize=1, hopCount=0 → zero-hop direct
outPath: Uint8List(64),
);
await _activeService.addOrUpdateContact(updatedContact);
} catch (e) {
_error = 'Failed to set direct path: $e';
notifyListeners();
rethrow;
}
}
/// Remove a contact from the companion radio
///
/// Deletes the contact from the device's internal contact table.
@@ -2753,6 +3035,12 @@ class ConnectionProvider with ChangeNotifier {
_rxActivityTimer?.cancel();
_txActivityTimer?.cancel();
_stopAckCleanupTimer();
final disconnectTransport = _serialDisconnectTransport;
if (disconnectTransport != null) {
unawaited(disconnectTransport());
_serialDisconnectTransport = null;
}
_serialService?.dispose();
_bleService.dispose();
_tcpService?.dispose();
super.dispose();

View File

@@ -7,6 +7,7 @@ import '../models/contact_group.dart';
import '../models/message_contact_location.dart';
import '../services/cayenne_lpp_parser.dart';
import '../services/contact_storage_service.dart';
import '../services/profiles_feature_service.dart';
import '../utils/fast_gps_packet.dart';
import '../utils/rssi_location_estimator.dart';
import '../utils/key_comparison.dart';
@@ -126,11 +127,13 @@ class _RetainedRoute {
class ContactsProvider with ChangeNotifier {
static const double _firstHopFallbackOffsetMeters = 100.0;
static const String autoGroupIdPrefix = 'auto_group_';
static const String _rawTelemetryHexKey = '__raw_lpp_hex';
final Map<String, Contact> _contacts = {};
final List<SavedContactGroup> _savedContactGroups = <SavedContactGroup>[];
final Map<String, PendingAdvert> _pendingAdverts = {};
final Map<String, LatLng> _estimatedLocations = {};
final Map<String, List<RssiObservation>> _rssiObservations = {};
final Map<String, Contact> _retainedContactsForSync = {};
final ContactStorageService _storageService = ContactStorageService();
bool _isInitialized = false;
bool _isPersisting = false;
@@ -138,9 +141,12 @@ class ContactsProvider with ChangeNotifier {
bool _isPersistingPendingAdverts = false;
bool _persistPendingAdvertsRequested = false;
String? _storageNamespace;
String? _selfPublicKeyHex;
ContactTelemetry? _selfTelemetry;
// Add default public channel on initialization
ContactsProvider() {
ContactsProvider()
: _storageNamespace = ProfileStorageScope.effectiveNamespace {
_ensurePublicChannelExists();
}
@@ -160,6 +166,7 @@ class ContactsProvider with ChangeNotifier {
Uint8List? devicePublicKey,
}) async {
_storageNamespace = namespace;
_setSelfDevicePublicKey(devicePublicKey);
await _loadFromStorage(force: true, devicePublicKey: devicePublicKey);
}
@@ -251,6 +258,7 @@ class ContactsProvider with ChangeNotifier {
/// Initialize and load persisted contacts
/// [devicePublicKey] - device's own public key to exclude from loaded contacts
Future<void> initialize({Uint8List? devicePublicKey}) async {
_setSelfDevicePublicKey(devicePublicKey);
if (_isInitialized) {
// If already initialized (from early load), just filter out self-contact
if (devicePublicKey != null) {
@@ -265,6 +273,36 @@ class ContactsProvider with ChangeNotifier {
}
}
/// Clear runtime contact state before a live device contact sync begins.
///
/// This intentionally does not touch persisted storage. It snapshots the
/// current in-memory contacts so sync updates can still merge against the
/// latest local state, but keeps the visible list intact so reconnects do
/// not blank the UI while the device is resyncing.
Future<void> prepareForDeviceContactSync({Uint8List? devicePublicKey}) async {
_setSelfDevicePublicKey(devicePublicKey);
if (!_isInitialized) {
final storedGroups = await _storageService.loadContactGroups(
namespace: _storageNamespace,
);
_savedContactGroups
..clear()
..addAll(storedGroups);
_isInitialized = true;
}
debugPrint(
'🧹 [ContactsProvider] Preparing retained contact state for device sync',
);
_retainedContactsForSync
..clear()
..addEntries(
_contactsForStorage().map(
(contact) => MapEntry(contact.publicKeyHex, contact),
),
);
}
/// Remove self-contact from loaded contacts (called after BLE connection established)
void _removeSelfContact(Uint8List devicePublicKey) {
final selfKeyHex = devicePublicKey
@@ -345,6 +383,7 @@ class ContactsProvider with ChangeNotifier {
}
List<Contact> get contacts => _contacts.values.toList();
ContactTelemetry? get selfTelemetry => _selfTelemetry;
List<Contact> get favouriteContacts =>
_contacts.values.where((c) => c.isFavourite).toList();
List<SavedContactGroup> get savedContactGroups =>
@@ -535,9 +574,11 @@ class ContactsProvider with ChangeNotifier {
// Replace existing observation from the same repeater, or add new
final repeaterKey =
'${observation.repeaterLocation.latitude},${observation.repeaterLocation.longitude}';
observations.removeWhere((o) =>
'${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' ==
repeaterKey);
observations.removeWhere(
(o) =>
'${o.repeaterLocation.latitude},${o.repeaterLocation.longitude}' ==
repeaterKey,
);
observations.add(observation);
// Keep at most 8 observations (most recent per repeater)
@@ -637,7 +678,9 @@ class ContactsProvider with ChangeNotifier {
}
// Check if this is a new contact
final existingContact = _contacts[contact.publicKeyHex];
final existingContact =
_contacts[contact.publicKeyHex] ??
_retainedContactsForSync[contact.publicKeyHex];
final isNewContact = existingContact == null;
debugPrint(
' isNew: $isNewContact, total contacts before: ${_contacts.length}',
@@ -649,6 +692,7 @@ class ContactsProvider with ChangeNotifier {
);
_contacts[contact.publicKeyHex] = updatedContact;
_retainedContactsForSync.remove(contact.publicKeyHex);
// Keep repeaters and sensors in pending adverts so they remain visible
// in the discovery list (with a checkmark). Remove others.
if (contact.type != ContactType.repeater &&
@@ -678,11 +722,14 @@ class ContactsProvider with ChangeNotifier {
excluded++;
continue;
}
final existingContact = _contacts[contact.publicKeyHex];
final existingContact =
_contacts[contact.publicKeyHex] ??
_retainedContactsForSync[contact.publicKeyHex];
_contacts[contact.publicKeyHex] = _mergeIncomingContact(
incomingContact: contact,
existingContact: existingContact,
);
_retainedContactsForSync.remove(contact.publicKeyHex);
if (contact.type != ContactType.repeater &&
contact.type != ContactType.sensor) {
_pendingAdverts.remove(contact.publicKeyHex);
@@ -954,17 +1001,29 @@ class ContactsProvider with ChangeNotifier {
// Find contact by public key prefix
final contact = _findContactByPrefix(publicKeyPrefix);
if (contact == null) {
final isSelfTelemetry =
contact == null && _matchesSelfPrefix(publicKeyPrefix);
if (contact == null && !isSelfTelemetry) {
debugPrint(' ❌ Contact not found for this prefix');
return;
}
debugPrint(' ✅ Found contact: ${contact.advName}');
debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}');
if (isSelfTelemetry) {
debugPrint(' ✅ Matched self telemetry response');
debugPrint(
' Old self telemetry timestamp: ${_selfTelemetry?.timestamp}',
);
} else {
debugPrint(' ✅ Found contact: ${contact!.advName}');
debugPrint(' Old telemetry timestamp: ${contact.telemetry?.timestamp}');
}
try {
// Parse Cayenne LPP data
var telemetry = CayenneLppParser.parse(lppData);
var telemetry = _withRawTelemetryHex(
CayenneLppParser.parse(lppData),
lppData,
);
debugPrint(' ✅ Parsed new telemetry');
debugPrint(' New telemetry timestamp: ${telemetry.timestamp}');
@@ -983,7 +1042,9 @@ class ContactsProvider with ChangeNotifier {
);
}
final previousTelemetry = contact.telemetry;
final previousTelemetry = isSelfTelemetry
? _selfTelemetry
: contact!.telemetry;
final mergedTelemetry = _mergeTelemetryForContact(
existingTelemetry: previousTelemetry,
@@ -1006,29 +1067,38 @@ class ContactsProvider with ChangeNotifier {
timestamp: mergedTelemetry.timestamp,
humidity: mergedTelemetry.humidity,
pressure: mergedTelemetry.pressure,
extraSensorData: telemetry.extraSensorData,
extraSensorData: mergedTelemetry.extraSensorData,
);
}
if (isSelfTelemetry) {
_selfTelemetry = telemetry;
notifyListeners();
debugPrint(' ✅ Updated self telemetry');
return;
}
final resolvedContact = contact!;
// Update contact with new telemetry AND last seen time
// lastAdvert is Unix timestamp in seconds
final currentTimestamp = (DateTime.now().millisecondsSinceEpoch / 1000)
.round();
debugPrint(' Old lastAdvert: ${contact.lastAdvert}');
debugPrint(' Old lastAdvert: ${resolvedContact.lastAdvert}');
debugPrint(' New lastAdvert: $currentTimestamp');
final persistedGps = _getValidGpsOrNull(telemetry.gpsLocation);
final updatedContact = contact.copyWith(
final updatedContact = resolvedContact.copyWith(
telemetry: telemetry,
lastAdvert: currentTimestamp, // Update last seen time
advLat: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.latitude)
: contact.advLat,
: resolvedContact.advLat,
advLon: persistedGps != null
? _coordinateToAdvertMicrodegrees(persistedGps.longitude)
: contact.advLon,
: resolvedContact.advLon,
);
_contacts[contact.publicKeyHex] = updatedContact;
_contacts[resolvedContact.publicKeyHex] = updatedContact;
debugPrint(' ✅ Updated contact in map (with new lastAdvert)');
_persistContacts();
@@ -1067,13 +1137,34 @@ class ContactsProvider with ChangeNotifier {
),
);
debugPrint(
'📥 [ContactsProvider] Fast GPS received '
'sender=${packet.senderKey6} '
'contact=${contact.displayName} '
'contactKey=${contact.publicKeyHex} '
'lat=${packet.latitude} '
'lon=${packet.longitude} '
'ts=${packet.timestampSeconds}',
);
final updatedContact = contact.copyWith(
telemetry: updatedTelemetry,
lastAdvert: packet.timestampSeconds,
lastMod: packet.timestampSeconds,
advLat: _coordinateToAdvertMicrodegrees(packet.latitude),
advLon: _coordinateToAdvertMicrodegrees(packet.longitude),
);
_contacts[contact.publicKeyHex] = updatedContact;
_estimatedLocations[contact.publicKeyHex] = LatLng(
packet.latitude,
packet.longitude,
);
debugPrint(
'✅ [ContactsProvider] Fast GPS applied '
'contact=${updatedContact.displayName} '
'contactKey=${updatedContact.publicKeyHex} '
'lastAdvert=${updatedContact.lastAdvert}',
);
_persistContacts();
notifyListeners();
}
@@ -1146,15 +1237,43 @@ class ContactsProvider with ChangeNotifier {
);
}
ContactTelemetry _withRawTelemetryHex(
ContactTelemetry telemetry,
Uint8List lppData,
) {
final extraSensorData = <String, dynamic>{...?telemetry.extraSensorData};
extraSensorData[_rawTelemetryHexKey] = _bytesToHex(lppData);
return ContactTelemetry(
gpsLocation: telemetry.gpsLocation,
batteryPercentage: telemetry.batteryPercentage,
batteryMilliVolts: telemetry.batteryMilliVolts,
temperature: telemetry.temperature,
timestamp: telemetry.timestamp,
humidity: telemetry.humidity,
pressure: telemetry.pressure,
extraSensorData: extraSensorData,
);
}
String _bytesToHex(Uint8List bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
}
Map<String, dynamic> _mergeExtraSensorData(
Map<String, dynamic>? existingExtraSensorData,
Map<String, dynamic>? incomingExtraSensorData,
) {
final merged = <String, dynamic>{...?existingExtraSensorData};
if (incomingExtraSensorData == null || incomingExtraSensorData.isEmpty) {
return merged;
return <String, dynamic>{...?existingExtraSensorData};
}
final isFullTelemetryRefresh = incomingExtraSensorData.containsKey(
_rawTelemetryHexKey,
);
final merged = <String, dynamic>{
if (!isFullTelemetryRefresh) ...?existingExtraSensorData,
};
final incomingMetricFamilies = incomingExtraSensorData.keys
.map(_telemetryMetricFamilyForKey)
.whereType<String>()
@@ -1195,10 +1314,11 @@ class ContactsProvider with ChangeNotifier {
/// Find contact by public key prefix (6 bytes)
Contact? _findContactByPrefix(Uint8List prefix) {
if (prefix.length < 6) return null;
if (prefix.isEmpty) return null;
final takeLen = prefix.length < 6 ? prefix.length : 6;
final prefixHex = prefix
.sublist(0, 6)
.sublist(0, takeLen)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
@@ -1234,6 +1354,36 @@ class ContactsProvider with ChangeNotifier {
return _contacts[keyHex];
}
void _setSelfDevicePublicKey(Uint8List? devicePublicKey) {
final nextKeyHex = _publicKeyHexOrNull(devicePublicKey);
if (_selfPublicKeyHex != nextKeyHex) {
_selfTelemetry = null;
}
_selfPublicKeyHex = nextKeyHex;
}
String? _publicKeyHexOrNull(Uint8List? publicKey) {
if (publicKey == null || publicKey.isEmpty) {
return null;
}
return publicKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
}
bool _matchesSelfPrefix(Uint8List prefix) {
final selfKeyHex = _selfPublicKeyHex;
if (selfKeyHex == null || prefix.isEmpty) {
return false;
}
final takeLen = prefix.length < 6 ? prefix.length : 6;
final prefixHex = prefix
.sublist(0, takeLen)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
return selfKeyHex.startsWith(prefixHex);
}
/// Clear a contact's learned path locally so the UI and next send both
/// prefer flood routing until the radio reports a fresh route.
void markPathUnhealthy(Uint8List publicKey) {
@@ -1531,6 +1681,7 @@ class ContactsProvider with ChangeNotifier {
void clearContacts() {
_contacts.clear();
_pendingAdverts.clear();
_retainedContactsForSync.clear();
_ensurePublicChannelExists();
_persistContacts();
_persistPendingAdverts();
@@ -1555,6 +1706,7 @@ class ContactsProvider with ChangeNotifier {
// Then remove from local storage
_contacts.remove(publicKeyHex);
_pendingAdverts.remove(publicKeyHex);
_retainedContactsForSync.remove(publicKeyHex);
_persistContacts();
_persistPendingAdverts();
notifyListeners();
@@ -1637,6 +1789,10 @@ class ContactsProvider with ChangeNotifier {
_contacts.clear();
_savedContactGroups.clear();
_pendingAdverts.clear();
_estimatedLocations.clear();
_rssiObservations.clear();
_retainedContactsForSync.clear();
_selfTelemetry = null;
}
Map<String, dynamic> _pendingAdvertToJson(PendingAdvert advert) {

View File

@@ -21,8 +21,8 @@ class MessageRetryManager {
final Map<String, int> _pathFailureStreaks = {};
/// Max retry attempts when the contact has a known path.
/// Official MeshCore app uses 5 (with auto-retry) or 3 (without).
static const int maxRetryAttemptsWithPath = 5;
/// Sequence: 2 direct attempts, flood, then last successful route.
static const int maxRetryAttemptsWithPath = 3;
/// No retries for flood-only contacts (no known path).
/// Value 0 means: don't retry at all, go straight to fallback/fail.

View File

@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../services/profiles_feature_service.dart';
import 'helpers/raw_session_retransmit.dart';
import '../utils/image_message_parser.dart';
@@ -75,6 +76,8 @@ class ImageProvider with ChangeNotifier {
_restore();
}
String _scopedStorageKey() => ProfileStorageScope.scopedKey(_storageKey);
// ── Accessors ────────────────────────────────────────────────────────────
ImageSession? session(String sessionId) => _sessions[sessionId];
@@ -292,18 +295,22 @@ class ImageProvider with ChangeNotifier {
// ── Persistence ──────────────────────────────────────────────────────────
Future<void> clearAll() async {
_sessions.clear();
_outgoing.clear();
_ignoredIncomingSessions.clear();
_resetInMemoryState();
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_storageKey);
await prefs.remove(_scopedStorageKey());
} catch (e) {
debugPrint('❌ [ImageProvider] Failed to clear storage: $e');
}
}
Future<void> reloadProfileScopedState() async {
_resetInMemoryState();
await _restore();
notifyListeners();
}
void _evictExpiredOutgoing() {
final now = DateTime.now();
_outgoing.removeWhere((_, s) => now.difference(s.cachedAt) > _outgoingTtl);
@@ -343,7 +350,7 @@ class ImageProvider with ChangeNotifier {
)
.toList(),
};
await prefs.setString(_storageKey, jsonEncode(payload));
await prefs.setString(_scopedStorageKey(), jsonEncode(payload));
} catch (e) {
debugPrint('❌ [ImageProvider] Failed to persist: $e');
}
@@ -352,7 +359,7 @@ class ImageProvider with ChangeNotifier {
Future<void> _restore() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_storageKey);
final raw = prefs.getString(_scopedStorageKey());
if (raw == null || raw.isEmpty) return;
final parsed = jsonDecode(raw) as Map<String, dynamic>;
@@ -428,6 +435,12 @@ class ImageProvider with ChangeNotifier {
debugPrint('❌ [ImageProvider] Failed to restore: $e');
}
}
void _resetInMemoryState() {
_sessions.clear();
_outgoing.clear();
_ignoredIncomingSessions.clear();
}
}
class _OutgoingSession {

View File

@@ -37,10 +37,9 @@ class MapProvider with ChangeNotifier {
MapCoordinateSpace _targetCoordinateSpace = MapCoordinateSpace.geo;
String? _targetMapId;
final Set<String> _visibleContactPaths = {};
LocationTrail? _currentTrail;
bool _isTrailVisible = true;
bool _isTrailRecordingEnabled = true;
final List<LocationTrail> _trailHistory = [];
bool _showCadastralOverlay = false;
@@ -55,7 +54,6 @@ class MapProvider with ChangeNotifier {
bool _showPlaceNamesOverlay = false;
bool _showMunicipalityBordersOverlay = false;
bool _showAllContactTrails = true;
bool _hideRepeatersOnMap = false;
LocationTrail? _importedTrail;
@@ -72,10 +70,9 @@ class MapProvider with ChangeNotifier {
bool get shouldAnimate => _shouldAnimate;
MapCoordinateSpace get targetCoordinateSpace => _targetCoordinateSpace;
String? get targetMapId => _targetMapId;
Set<String> get visibleContactPaths => Set.unmodifiable(_visibleContactPaths);
LocationTrail? get currentTrail => _currentTrail;
bool get isTrailVisible => _isTrailVisible;
bool get isTrailRecordingEnabled => _isTrailRecordingEnabled;
List<LocationTrail> get trailHistory => List.unmodifiable(_trailHistory);
bool get isTrailActive => _currentTrail?.isActive ?? false;
@@ -91,7 +88,6 @@ class MapProvider with ChangeNotifier {
bool get showPlaceNamesOverlay => _showPlaceNamesOverlay;
bool get showMunicipalityBordersOverlay => _showMunicipalityBordersOverlay;
bool get showAllContactTrails => _showAllContactTrails;
bool get hideRepeatersOnMap => _hideRepeatersOnMap;
LocationTrail? get importedTrail => _importedTrail;
@@ -365,30 +361,6 @@ class MapProvider with ChangeNotifier {
notifyListeners();
}
void toggleContactPath(String publicKeyHex) {
if (_visibleContactPaths.contains(publicKeyHex)) {
_visibleContactPaths.remove(publicKeyHex);
} else {
_visibleContactPaths.add(publicKeyHex);
}
notifyListeners();
}
bool isContactPathVisible(String publicKeyHex) {
return _visibleContactPaths.contains(publicKeyHex);
}
void hideAllPaths() {
_visibleContactPaths.clear();
notifyListeners();
}
void showOnlyPath(String publicKeyHex) {
_visibleContactPaths.clear();
_visibleContactPaths.add(publicKeyHex);
notifyListeners();
}
void startTrail() {
if (_currentTrail != null && _currentTrail!.isActive) {
endTrail();
@@ -403,6 +375,10 @@ class MapProvider with ChangeNotifier {
}
void addTrailPoint(LatLng position, {double? accuracy, double? speed}) {
if (!_isTrailRecordingEnabled) {
return;
}
if (_currentTrail == null || !_currentTrail!.isActive) {
startTrail();
}
@@ -435,6 +411,24 @@ class MapProvider with ChangeNotifier {
notifyListeners();
}
void setTrailRecordingEnabled(bool enabled) {
if (_isTrailRecordingEnabled == enabled) {
return;
}
_isTrailRecordingEnabled = enabled;
if (!enabled) {
if (_currentTrail != null) {
endTrail();
} else {
notifyListeners();
}
return;
}
notifyListeners();
}
void clearCurrentTrail() {
if (_currentTrail != null) {
_currentTrail = null;
@@ -556,12 +550,17 @@ class MapProvider with ChangeNotifier {
Future<void> _loadInitialState() async {
await Future.wait([
loadOverlayState(),
loadTrailSettings(),
loadRepeaterVisibilitySettings(),
_loadCustomMapState(),
_removeDeprecatedSettings(),
]);
}
Future<void> _removeDeprecatedSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_scopedKey('map_show_all_contact_trails'));
}
Future<void> _saveOverlayState() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
@@ -610,27 +609,6 @@ class MapProvider with ChangeNotifier {
);
}
Future<void> toggleAllContactTrails() async {
_showAllContactTrails = !_showAllContactTrails;
notifyListeners();
await _saveTrailSettings();
}
Future<void> loadTrailSettings() async {
final prefs = await SharedPreferences.getInstance();
_showAllContactTrails =
prefs.getBool(_scopedKey('map_show_all_contact_trails')) ?? true;
notifyListeners();
}
Future<void> _saveTrailSettings() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
_scopedKey('map_show_all_contact_trails'),
_showAllContactTrails,
);
}
Future<void> setHideRepeatersOnMap(bool hide) async {
if (_hideRepeatersOnMap == hide) return;
_hideRepeatersOnMap = hide;
@@ -744,6 +722,7 @@ class MapProvider with ChangeNotifier {
'trailHistory': _trailHistory.map((trail) => trail.toJson()).toList(),
'importedTrail': _importedTrail?.toJson(),
'isTrailVisible': _isTrailVisible,
'isTrailRecordingEnabled': _isTrailRecordingEnabled,
'showCadastralOverlay': _showCadastralOverlay,
'showForestRoadsOverlay': _showForestRoadsOverlay,
'showHikingTrailsOverlay': _showHikingTrailsOverlay,
@@ -755,7 +734,6 @@ class MapProvider with ChangeNotifier {
'showKrasFireZonesOverlay': _showKrasFireZonesOverlay,
'showPlaceNamesOverlay': _showPlaceNamesOverlay,
'showMunicipalityBordersOverlay': _showMunicipalityBordersOverlay,
'showAllContactTrails': _showAllContactTrails,
'hideRepeatersOnMap': _hideRepeatersOnMap,
};
}
@@ -775,6 +753,8 @@ class MapProvider with ChangeNotifier {
? LocationTrail.fromJson(json['importedTrail'] as Map<String, dynamic>)
: null;
_isTrailVisible = json['isTrailVisible'] as bool? ?? true;
_isTrailRecordingEnabled =
json['isTrailRecordingEnabled'] as bool? ?? true;
_showCadastralOverlay = json['showCadastralOverlay'] as bool? ?? false;
_showForestRoadsOverlay = json['showForestRoadsOverlay'] as bool? ?? false;
_showHikingTrailsOverlay =
@@ -792,7 +772,6 @@ class MapProvider with ChangeNotifier {
_showPlaceNamesOverlay = json['showPlaceNamesOverlay'] as bool? ?? false;
_showMunicipalityBordersOverlay =
json['showMunicipalityBordersOverlay'] as bool? ?? false;
_showAllContactTrails = json['showAllContactTrails'] as bool? ?? true;
_hideRepeatersOnMap = json['hideRepeatersOnMap'] as bool? ?? false;
notifyListeners();
}

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/message.dart';
import '../models/contact.dart';
import '../models/message_contact_location.dart';
@@ -10,6 +11,7 @@ import '../models/path_selection.dart';
import '../models/sar_marker.dart';
import '../models/map_drawing.dart';
import '../services/message_storage_service.dart';
import '../services/location_tracking_service.dart';
import '../services/notification_service.dart';
import '../utils/sar_message_parser.dart';
import '../utils/drawing_message_parser.dart';
@@ -23,6 +25,8 @@ typedef DisplayMessageEntry = ({Message message, int occurrenceCount});
/// Messages Provider - manages message history and SAR markers
class MessagesProvider with ChangeNotifier {
static const Duration _channelEchoWarningDelay = Duration(seconds: 12);
static const Duration _receivedDuplicateWindow = Duration(seconds: 5);
static const Duration _deliveredRouteRefreshWindow = Duration(seconds: 30);
final List<Message> _messages = [];
final Map<String, SarMarker> _sarMarkers = {};
@@ -37,6 +41,9 @@ class MessagesProvider with ChangeNotifier {
final Map<String, MessageReceptionDetails> _messageReceptionDetails = {};
final Map<String, MessageTransferDetails> _messageTransferDetails = {};
final Map<String, MessageRouteMetadata> _messageRouteMetadata = {};
final Map<String, List<_PendingDeliveredRouteRefresh>>
_pendingDeliveredRouteRefreshByContact =
<String, List<_PendingDeliveredRouteRefresh>>{};
String? _storageNamespace;
// Track pending sent messages by expected ACK/TAG
@@ -191,6 +198,9 @@ class MessagesProvider with ChangeNotifier {
_messages[index] = _messages[index].copyWith(
usedFloodFallback: selection.usesFlood,
pathLen: nextPathLen,
pathBytes: selection.hasDirectPath
? Uint8List.fromList(selection.pathBytes)
: Uint8List(0),
);
}
@@ -198,6 +208,83 @@ class MessagesProvider with ChangeNotifier {
notifyListeners();
}
void queueDeliveredMessageRouteRefresh(String messageId, Contact contact) {
if (messageId.isEmpty || contact.publicKeyHex.isEmpty) {
return;
}
_prunePendingDeliveredRouteRefresh();
final queue = _pendingDeliveredRouteRefreshByContact.putIfAbsent(
contact.publicKeyHex,
() => <_PendingDeliveredRouteRefresh>[],
);
queue.removeWhere((entry) => entry.messageId == messageId);
queue.add(
_PendingDeliveredRouteRefresh(
messageId: messageId,
queuedAt: DateTime.now(),
),
);
}
bool applyDeliveredMessageRouteFromContact(Contact contact) {
if (!contact.routeHasPath ||
contact.routeHopCount <= 0 ||
contact.publicKeyHex.isEmpty) {
return false;
}
_prunePendingDeliveredRouteRefresh();
final queue = _pendingDeliveredRouteRefreshByContact[contact.publicKeyHex];
if (queue == null || queue.isEmpty) {
return false;
}
while (queue.isNotEmpty) {
final pending = queue.removeAt(0);
final index = _messages.indexWhere(
(message) => message.id == pending.messageId,
);
if (index == -1) {
continue;
}
final message = _messages[index];
if (!message.isContactMessage ||
message.deliveryStatus != MessageDeliveryStatus.delivered) {
continue;
}
final existingMetadata = _messageRouteMetadata[pending.messageId];
_messageRouteMetadata[pending.messageId] = MessageRouteMetadata(
mode: PathSelectionMode.directCurrent,
routerFallbackAttempted:
existingMetadata?.routerFallbackAttempted ?? false,
canonicalPath: contact.routeCanonicalText.isEmpty
? null
: contact.routeCanonicalText,
hopCount: contact.routeHopCount,
);
_messages[index] = message.copyWith(
pathLen: contact.routeHopCount,
usedFloodFallback: false,
);
if (queue.isEmpty) {
_pendingDeliveredRouteRefreshByContact.remove(contact.publicKeyHex);
}
_persistMessages();
notifyListeners();
return true;
}
if (queue.isEmpty) {
_pendingDeliveredRouteRefreshByContact.remove(contact.publicKeyHex);
}
return false;
}
/// Set localizations for notifications
void setLocalizations(AppLocalizations localizations) {
_localizations = localizations;
@@ -375,23 +462,12 @@ class MessagesProvider with ChangeNotifier {
.loadMessageRouteMetadata(namespace: _storageNamespace);
final storedRemovedSarMarkerIds = await _storageService
.loadRemovedSarMarkerIds(namespace: _storageNamespace);
_messageContactLocations
..clear()
..addAll(storedContactLocations);
_messageReceptionDetails
..clear()
..addAll(storedReceptionDetails);
_messageTransferDetails
..clear()
..addAll(storedTransferDetails);
_messageRouteMetadata
..clear()
..addAll(storedRouteMetadata);
_removedSarMarkerIds
..clear()
..addAll(storedRemovedSarMarkerIds);
// Add stored messages with enhancement to ensure SAR detection
// Rebuild the in-memory list through the normal duplicate rules so
// stale duplicate rows already persisted on-device are collapsed.
for (final message in storedMessages) {
// Re-enhance each message to ensure SAR markers are properly detected
// This handles cases where messages were stored before enhancement logic
@@ -432,7 +508,66 @@ class MessagesProvider with ChangeNotifier {
}
}
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
final duplicateIndex = _findDuplicateMessageIndex(enhancedMessage);
if (duplicateIndex != -1) {
final existingId = _messages[duplicateIndex].id;
final incomingReceptionDetails = storedReceptionDetails[message.id];
final existingMessage = _messages[duplicateIndex];
final duplicatePathBytes = incomingReceptionDetails?.pathBytes;
final storedLocation = storedContactLocations[message.id];
if (storedLocation != null &&
!_messageContactLocations.containsKey(existingId)) {
_messageContactLocations[existingId] = storedLocation;
}
_messageReceptionDetails[existingId] =
MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
incoming: incomingReceptionDetails,
);
final storedTransferDetailsForMessage = storedTransferDetails[message.id];
if (storedTransferDetailsForMessage != null &&
!_messageTransferDetails.containsKey(existingId)) {
_messageTransferDetails[existingId] = storedTransferDetailsForMessage;
}
final storedRouteMetadataForMessage = storedRouteMetadata[message.id];
if (storedRouteMetadataForMessage != null &&
!_messageRouteMetadata.containsKey(existingId)) {
_messageRouteMetadata[existingId] = storedRouteMetadataForMessage;
}
if (existingMessage.pathBytes == null &&
duplicatePathBytes != null &&
duplicatePathBytes.isNotEmpty) {
_messages[duplicateIndex] = existingMessage.copyWith(
pathBytes: Uint8List.fromList(duplicatePathBytes),
);
}
continue;
}
_messages.add(enhancedMessage);
final storedLocation = storedContactLocations[message.id];
if (storedLocation != null) {
_messageContactLocations[message.id] = storedLocation;
}
final storedReception = storedReceptionDetails[message.id];
if (storedReception != null) {
_messageReceptionDetails[message.id] = storedReception;
}
final storedTransfer = storedTransferDetails[message.id];
if (storedTransfer != null) {
_messageTransferDetails[message.id] = storedTransfer;
}
final storedRoute = storedRouteMetadata[message.id];
if (storedRoute != null) {
_messageRouteMetadata[message.id] = storedRoute;
}
// Extract SAR markers
if (enhancedMessage.isSarMarker) {
@@ -664,6 +799,42 @@ class MessagesProvider with ChangeNotifier {
return; // Skip duplicate
}
final matchingSentReplayIndex = _findMatchingSentReplayIndex(finalMessage);
if (matchingSentReplayIndex != -1) {
final existingId = _messages[matchingSentReplayIndex].id;
_clearChannelSendWarning(existingId);
if (contactLocationSnapshot != null) {
_messageContactLocations[existingId] = contactLocationSnapshot;
}
final mergedReceptionDetails = MessageReceptionDetails.mergeDuplicate(
existing: _messageReceptionDetails[existingId],
incoming: receptionDetailsSnapshot,
);
_messageReceptionDetails[existingId] = mergedReceptionDetails;
final existingMessage = _messages[matchingSentReplayIndex];
final routeMetadata = _messageRouteMetadata[existingId];
_messages[matchingSentReplayIndex] = existingMessage.copyWith(
echoCount: _mergeSentReplayEchoCount(
existingMessage,
mergedReceptionDetails,
),
pathLen: _mergeSentReplayPathLen(
existingMessage,
finalMessage,
routeMetadata,
),
pathBytes: _mergeSentReplayPathBytes(
existingMessage,
finalMessage,
routeMetadata,
),
firstEchoAt: existingMessage.firstEchoAt ?? DateTime.now(),
lastEchoAt: DateTime.now(),
);
_persistMessages();
return;
}
_messages.add(finalMessage);
if (contactLocationSnapshot != null) {
_messageContactLocations[finalMessage.id] = contactLocationSnapshot;
@@ -710,10 +881,27 @@ class MessagesProvider with ChangeNotifier {
return -1;
}
final exactDuplicateIndex = _findExactDuplicateMessageIndex(message);
if (exactDuplicateIndex != -1) {
return exactDuplicateIndex;
}
final lastConversationDuplicateIndex =
_findLastConversationDuplicateMessageIndex(message);
if (lastConversationDuplicateIndex != -1) {
return lastConversationDuplicateIndex;
}
return -1;
}
int _findExactDuplicateMessageIndex(Message message) {
final normalizedIncomingText = _normalizedDuplicateText(message.text);
for (int index = 0; index < _messages.length; index++) {
final existing = _messages[index];
if (!_matchesDuplicateScope(existing, message) ||
existing.text != message.text) {
if (existing.isSentMessage ||
!_matchesExactDuplicateScope(existing, message) ||
_normalizedDuplicateText(existing.text) != normalizedIncomingText) {
continue;
}
if (message.isChannelMessage) {
@@ -729,16 +917,42 @@ class MessagesProvider with ChangeNotifier {
return -1;
}
bool _matchesDuplicateScope(Message existing, Message message) {
int _findLastConversationDuplicateMessageIndex(Message message) {
final normalizedIncomingText = _normalizedDuplicateText(message.text);
for (int index = _messages.length - 1; index >= 0; index--) {
final existing = _messages[index];
if (!_isSameConversation(existing, message)) {
continue;
}
final receivedDelta = existing.receivedAt.difference(message.receivedAt).abs();
if (receivedDelta > _receivedDuplicateWindow) {
continue;
}
if (existing.isSentMessage || existing.isSystemMessage) {
continue;
}
if (_normalizedDuplicateText(existing.text) != normalizedIncomingText) {
continue;
}
if (_matchesDuplicateSenderIdentity(existing, message)) {
return index;
}
}
return -1;
}
bool _matchesExactDuplicateScope(Message existing, Message message) {
if (existing.messageType != message.messageType) {
return false;
}
if (message.isContactMessage) {
// Match by sender key + sender timestamp (matches official app's DB
// uniqueness: contactPublicKey + senderTimestamp + text + txtType).
return existing.senderKeyShort == message.senderKeyShort &&
existing.senderTimestamp == message.senderTimestamp;
if (!_isSameConversation(existing, message)) {
return false;
}
return existing.senderTimestamp == message.senderTimestamp &&
_matchesDuplicateSenderIdentity(existing, message);
}
if (message.isChannelMessage) {
@@ -746,36 +960,9 @@ class MessagesProvider with ChangeNotifier {
return false;
}
// Primary dedup: same senderTimestamp = same message from mesh repeats.
// Matches official app's DB uniqueness: (channelSecret, senderTimestamp, text).
if (existing.senderTimestamp == message.senderTimestamp) {
return true;
}
// Secondary: catch near-duplicate repeats within a 30s window
// (clock drift between nodes).
final withinChannelRepeatWindow =
(existing.senderTimestamp - message.senderTimestamp).abs() <= 30;
if (!withinChannelRepeatWindow) {
return false;
}
final existingSenderKey = existing.senderKeyShort;
final incomingSenderKey = message.senderKeyShort;
if (existingSenderKey != null &&
incomingSenderKey != null &&
existingSenderKey == incomingSenderKey) {
return true;
}
final existingSenderName = _normalizedResolvedSenderName(existing);
final incomingSenderName = _normalizedResolvedSenderName(message);
if (existingSenderName != null &&
incomingSenderName != null &&
existingSenderName == incomingSenderName) {
return true;
}
return false;
}
@@ -783,6 +970,131 @@ class MessagesProvider with ChangeNotifier {
return false;
}
bool _isSameConversation(Message existing, Message message) {
if (existing.messageType != message.messageType) {
return false;
}
if (message.isChannelMessage) {
return existing.channelIdx == message.channelIdx;
}
if (!message.isContactMessage) {
return false;
}
if (existing.recipientPublicKey != null && message.recipientPublicKey != null) {
return _listEquals(existing.recipientPublicKey!, message.recipientPublicKey!);
}
if (existing.recipientPublicKey == null && message.recipientPublicKey == null) {
return true;
}
return false;
}
bool _matchesDuplicateSenderIdentity(Message existing, Message message) {
final existingSenderKey = existing.senderKeyShort;
final incomingSenderKey = message.senderKeyShort;
if (existingSenderKey != null &&
incomingSenderKey != null &&
existingSenderKey == incomingSenderKey) {
return true;
}
final existingSenderName = _normalizedResolvedSenderName(existing);
final incomingSenderName = _normalizedResolvedSenderName(message);
return existingSenderName != null &&
incomingSenderName != null &&
existingSenderName == incomingSenderName;
}
int _findMatchingSentReplayIndex(Message message) {
if (!message.isChannelMessage || message.isSentMessage) {
return -1;
}
final normalizedIncomingText = _normalizedDuplicateText(message.text);
for (int index = 0; index < _messages.length; index++) {
final existing = _messages[index];
if (!existing.isSentMessage ||
!existing.isChannelMessage ||
_normalizedDuplicateText(existing.text) != normalizedIncomingText) {
continue;
}
if (_matchesSentReplayScope(existing, message)) {
return index;
}
}
return -1;
}
bool _matchesSentReplayScope(Message existing, Message message) {
if (!_isSameConversation(existing, message)) {
return false;
}
if (existing.senderTimestamp == message.senderTimestamp) {
return true;
}
final withinChannelRepeatWindow =
(existing.senderTimestamp - message.senderTimestamp).abs() <= 30;
if (!withinChannelRepeatWindow) {
return false;
}
return _matchesDuplicateSenderIdentity(existing, message);
}
int _mergeSentReplayEchoCount(
Message existing,
MessageReceptionDetails mergedReceptionDetails,
) {
final replayCount = mergedReceptionDetails.receivedCopies > 0
? mergedReceptionDetails.receivedCopies - 1
: 0;
return replayCount > existing.echoCount ? replayCount : existing.echoCount;
}
int _mergeSentReplayPathLen(
Message existing,
Message incoming,
MessageRouteMetadata? routeMetadata,
) {
if (routeMetadata?.mode == PathSelectionMode.flood ||
existing.usedFloodFallback) {
return existing.pathLen;
}
final routeHopCount = routeMetadata?.hopCount;
if (routeHopCount != null && routeHopCount > 0) {
return routeHopCount;
}
if (existing.pathLen > 0) {
return existing.pathLen;
}
return incoming.pathLen > 0 ? incoming.pathLen : existing.pathLen;
}
Uint8List? _mergeSentReplayPathBytes(
Message existing,
Message incoming,
MessageRouteMetadata? routeMetadata,
) {
if (routeMetadata?.mode == PathSelectionMode.flood ||
existing.usedFloodFallback) {
return existing.pathBytes;
}
return existing.pathBytes ?? incoming.pathBytes;
}
/// Add multiple messages
void addMessages(List<Message> messages) {
int addedCount = 0;
@@ -896,6 +1208,10 @@ class MessagesProvider with ChangeNotifier {
return _normalizeSenderName(resolved);
}
String _normalizedDuplicateText(String value) {
return value.replaceAll('\r\n', '\n').trim();
}
void _scheduleChannelEchoWarning(String messageId) {
_channelEchoWarningTimers[messageId]?.cancel();
_channelEchoWarningMessageIds.remove(messageId);
@@ -1180,9 +1496,7 @@ class MessagesProvider with ChangeNotifier {
for (final message in messages) {
final occurrenceCount = _messageOccurrenceCount(message);
final existingIndex = entries.indexWhere(
(entry) =>
entry.message.text == message.text &&
_matchesDuplicateScope(entry.message, message),
(entry) => _shouldCollapseDisplayMessage(entry.message, message),
);
if (existingIndex == -1) {
@@ -1200,6 +1514,20 @@ class MessagesProvider with ChangeNotifier {
return entries;
}
bool _shouldCollapseDisplayMessage(Message existing, Message message) {
if (existing.isSentMessage || message.isSentMessage) {
return false;
}
final receivedDelta = existing.receivedAt.difference(message.receivedAt).abs();
return _normalizedDuplicateText(existing.text) ==
_normalizedDuplicateText(message.text) &&
(_matchesExactDuplicateScope(existing, message) ||
(receivedDelta <= _receivedDuplicateWindow &&
_isSameConversation(existing, message) &&
_matchesDuplicateSenderIdentity(existing, message)));
}
int _messageOccurrenceCount(Message message) =>
_messageReceptionDetails[message.id]?.receivedCopies ?? 1;
@@ -1606,12 +1934,17 @@ class MessagesProvider with ChangeNotifier {
}
}
enhancedMessage = _resolveSenderNameIfNeeded(enhancedMessage);
final senderLocationSnapshot = _buildSentMessageLocationSnapshot();
// Check for duplicates (shouldn't happen for sent messages, but be safe)
if (_findDuplicateMessageIndex(enhancedMessage) != -1) {
debugPrint(
'⚠️ [MessagesProvider] Duplicate sent message detected, skipping: ${enhancedMessage.id}',
);
if (senderLocationSnapshot != null) {
_messageContactLocations[enhancedMessage.id] = senderLocationSnapshot;
_persistMessages();
}
return;
}
@@ -1621,6 +1954,9 @@ class MessagesProvider with ChangeNotifier {
isRead: true, // Sent messages are always marked as read
);
_messages.add(sendingMessage);
if (senderLocationSnapshot != null) {
_messageContactLocations[sendingMessage.id] = senderLocationSnapshot;
}
debugPrint(' ✅ Message added to list at index ${_messages.length - 1}');
debugPrint(' Total messages in list: ${_messages.length}');
@@ -1648,6 +1984,20 @@ class MessagesProvider with ChangeNotifier {
debugPrint(' ✅ notifyListeners() called - UI should update');
}
MessageContactLocation? _buildSentMessageLocationSnapshot() {
final position = LocationTrackingService().currentPosition;
if (position == null) {
return null;
}
return MessageContactLocation(
location: LatLng(position.latitude, position.longitude),
source: 'gps',
capturedAt: DateTime.now(),
sourceTimestamp: position.timestamp,
);
}
/// Register an individual message ID as part of a grouped message
void registerGroupedMessageSend(
String individualMessageId,
@@ -1879,7 +2229,9 @@ class MessagesProvider with ChangeNotifier {
int echoCount,
int snrRaw,
int rssiDbm,
) {
{
Uint8List? pathBytes,
}) {
debugPrint('🔊 [MessagesProvider] handleMessageEcho called');
debugPrint(' Message ID: $messageId');
debugPrint(' Echo count: $echoCount');
@@ -1894,18 +2246,35 @@ class MessagesProvider with ChangeNotifier {
' ✅ Found message: ${message.text.substring(0, message.text.length > 30 ? 30 : message.text.length)}...',
);
final nextEchoCount = echoCount > message.echoCount
? echoCount
: message.echoCount + 1;
// Update echo count
final updatedMessage = message.copyWith(
echoCount: echoCount,
echoCount: nextEchoCount,
firstEchoAt: message.firstEchoAt ?? DateTime.now(),
lastEchoSnrRaw: snrRaw.toSigned(8),
lastEchoRssiDbm: rssiDbm.toSigned(8),
lastEchoAt: DateTime.now(),
);
_messages[index] = updatedMessage;
_messageReceptionDetails[messageId] = _messageReceptionDetails[messageId]
?.copyWith(
capturedAt: DateTime.now(),
rssiDbm: rssiDbm.toSigned(8),
snrDb: snrRaw.toSigned(8) / 4.0,
pathBytes: pathBytes?.toList(),
) ??
MessageReceptionDetails(
capturedAt: DateTime.now(),
rssiDbm: rssiDbm.toSigned(8),
snrDb: snrRaw.toSigned(8) / 4.0,
pathBytes: pathBytes?.toList(),
);
_clearChannelSendWarning(messageId);
debugPrint(' Updated echo count to: $echoCount');
debugPrint(' Updated echo count to: $nextEchoCount');
_persistMessages();
notifyListeners();
debugPrint(' ✅ Echo update complete, UI notified');
@@ -2347,17 +2716,6 @@ class MessagesProvider with ChangeNotifier {
return;
}
// On the last attempt, reset the path to force flood mode
// (matches official MeshCore app behaviour)
if (_retryManager.isLastAttempt(currentMessage, contact)) {
debugPrint(
'🔄 [MessagesProvider] Last attempt — resetting path to flood for $messageId',
);
if (resetPathBeforeLastRetryCallback != null) {
await resetPathBeforeLastRetryCallback!(contact);
}
}
if (sendMessageCallback != null) {
final queued = await sendMessageCallback!(
contactPublicKey: contact.publicKey,
@@ -2445,6 +2803,7 @@ class MessagesProvider with ChangeNotifier {
_pendingSentMessages.remove(message.expectedAckTag);
}
_clearAckHistoryForMessage(messageId);
_removePendingDeliveredRouteRefresh(messageId);
// Clear retry tracking
_retryManager.clearRetry(messageId);
@@ -2485,6 +2844,7 @@ class MessagesProvider with ChangeNotifier {
_pendingSentMessages.remove(message.expectedAckTag);
}
_clearAckHistoryForMessage(messageId);
_removePendingDeliveredRouteRefresh(messageId);
_retryManager.clearRetry(messageId);
_messageRouteMetadata.remove(messageId);
onManualRetryPreparedCallback?.call(messageId);
@@ -2657,4 +3017,51 @@ class MessagesProvider with ChangeNotifier {
}
}
}
void _prunePendingDeliveredRouteRefresh() {
if (_pendingDeliveredRouteRefreshByContact.isEmpty) {
return;
}
final cutoff = DateTime.now().subtract(_deliveredRouteRefreshWindow);
final emptyKeys = <String>[];
for (final entry in _pendingDeliveredRouteRefreshByContact.entries) {
entry.value.removeWhere(
(pending) => pending.queuedAt.isBefore(cutoff),
);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingDeliveredRouteRefreshByContact.remove(key);
}
}
void _removePendingDeliveredRouteRefresh(String messageId) {
if (_pendingDeliveredRouteRefreshByContact.isEmpty) {
return;
}
final emptyKeys = <String>[];
for (final entry in _pendingDeliveredRouteRefreshByContact.entries) {
entry.value.removeWhere((pending) => pending.messageId == messageId);
if (entry.value.isEmpty) {
emptyKeys.add(entry.key);
}
}
for (final key in emptyKeys) {
_pendingDeliveredRouteRefreshByContact.remove(key);
}
}
}
class _PendingDeliveredRouteRefresh {
final String messageId;
final DateTime queuedAt;
const _PendingDeliveredRouteRefresh({
required this.messageId,
required this.queuedAt,
});
}

View File

@@ -0,0 +1,541 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import '../models/map_layer.dart';
import '../services/offline_tile_cache_service.dart';
import '../services/tile_download_service.dart';
import '../services/tile_math_service.dart';
import '../services/tile_sharing_service.dart';
export '../services/tile_sharing_service.dart' show TilePeer, PeerCatalog;
export '../services/offline_tile_cache_service.dart'
show StyleInfo, DownloadRegion;
/// Download progress state.
class DownloadProgress {
final int downloaded;
final int skipped;
final int failed;
final int total;
const DownloadProgress({
this.downloaded = 0,
this.skipped = 0,
this.failed = 0,
this.total = 0,
});
int get processed => downloaded + skipped + failed;
double get percent => total == 0 ? 0 : processed / total;
bool get isComplete => total > 0 && processed >= total;
}
/// A downloaded/skipped tile rectangle for map overlay.
class TileOverlay {
final double north, south, east, west;
final bool isSkipped;
const TileOverlay({
required this.north,
required this.south,
required this.east,
required this.west,
this.isSkipped = false,
});
}
/// Drawing mode for polygon selection.
enum DrawingMode { none, polygon, rectangle }
/// State management for offline tile downloading.
class OfflineTilesProvider extends ChangeNotifier {
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
final TileSharingService _sharing = TileSharingService.instance;
TileDownloadService? _downloadService;
StreamSubscription<TileDownloadEvent>? _downloadSubscription;
StreamSubscription<Set<TilePeer>>? _peersSubscription;
// Drawing state
DrawingMode _drawingMode = DrawingMode.none;
final List<List<LatLng>> _polygons = [];
List<LatLng> _currentVertices = [];
LatLng? _rectangleFirstCorner;
// Download settings
int _minZoom = 8;
int _maxZoom = 14;
MapLayer _selectedLayer = MapLayer.openStreetMap;
// Download progress
bool _isDownloading = false;
DownloadProgress _progress = const DownloadProgress();
final List<TileOverlay> _tileOverlays = [];
// Cache info
int _cacheSizeBytes = 0;
// Sharing state
bool _isServerRunning = false;
Set<TilePeer> _discoveredPeers = {};
List<PeerCatalog> _peerCatalogs = [];
bool _isFetchingCatalogs = false;
bool _isSyncing = false;
String _syncStatus = '';
double _syncProgress = 0;
// Local style info
List<StyleInfo> _localStyles = [];
// Coverage overlay — which cached style's tiles to show on the map
StyleInfo? _coverageStyle;
List<TileOverlay> _coverageOverlays = [];
// Getters
DrawingMode get drawingMode => _drawingMode;
List<List<LatLng>> get polygons => List.unmodifiable(_polygons);
List<LatLng> get currentVertices => List.unmodifiable(_currentVertices);
LatLng? get rectangleFirstCorner => _rectangleFirstCorner;
int get minZoom => _minZoom;
int get maxZoom => _maxZoom;
MapLayer get selectedLayer => _selectedLayer;
bool get isDownloading => _isDownloading;
DownloadProgress get progress => _progress;
List<TileOverlay> get tileOverlays => _tileOverlays;
int get cacheSizeBytes => _cacheSizeBytes;
bool get hasPolygons => _polygons.isNotEmpty;
bool get isServerRunning => _isServerRunning;
Set<TilePeer> get discoveredPeers => _discoveredPeers;
List<PeerCatalog> get peerCatalogs => _peerCatalogs;
bool get isFetchingCatalogs => _isFetchingCatalogs;
bool get isSyncing => _isSyncing;
String get syncStatus => _syncStatus;
double get syncProgress => _syncProgress;
List<StyleInfo> get localStyles => _localStyles;
StyleInfo? get coverageStyle => _coverageStyle;
List<TileOverlay> get coverageOverlays => _coverageOverlays;
/// Estimated tile count for the current selection.
int get estimatedTileCount {
if (_polygons.isEmpty) return 0;
return TileMathService.estimateTileCount(_polygons, _minZoom, _maxZoom);
}
// Drawing methods
void setDrawingMode(DrawingMode mode) {
_drawingMode = mode;
_currentVertices = [];
_rectangleFirstCorner = null;
notifyListeners();
}
void addVertex(LatLng point) {
if (_drawingMode == DrawingMode.polygon) {
_currentVertices = [..._currentVertices, point];
notifyListeners();
} else if (_drawingMode == DrawingMode.rectangle) {
if (_rectangleFirstCorner == null) {
_rectangleFirstCorner = point;
notifyListeners();
} else {
// Complete rectangle
final corner1 = _rectangleFirstCorner!;
final corner2 = point;
final rect = [
LatLng(corner1.latitude, corner1.longitude),
LatLng(corner1.latitude, corner2.longitude),
LatLng(corner2.latitude, corner2.longitude),
LatLng(corner2.latitude, corner1.longitude),
];
_polygons.add(rect);
_rectangleFirstCorner = null;
_drawingMode = DrawingMode.none;
notifyListeners();
}
}
}
void finishPolygon() {
if (_drawingMode == DrawingMode.polygon && _currentVertices.length >= 3) {
_polygons.add(List.from(_currentVertices));
_currentVertices = [];
_drawingMode = DrawingMode.none;
notifyListeners();
}
}
void removePolygon(int index) {
if (index >= 0 && index < _polygons.length) {
_polygons.removeAt(index);
notifyListeners();
}
}
void clearPolygons() {
_polygons.clear();
_currentVertices = [];
_rectangleFirstCorner = null;
_drawingMode = DrawingMode.none;
notifyListeners();
}
void undoLastVertex() {
if (_currentVertices.isNotEmpty) {
_currentVertices = _currentVertices.sublist(0, _currentVertices.length - 1);
notifyListeners();
}
}
// Download settings
void setMinZoom(int zoom) {
_minZoom = zoom.clamp(0, 19);
if (_maxZoom < _minZoom) _maxZoom = _minZoom;
notifyListeners();
}
void setMaxZoom(int zoom) {
_maxZoom = zoom.clamp(0, 19);
if (_minZoom > _maxZoom) _minZoom = _maxZoom;
notifyListeners();
}
void setSelectedLayer(MapLayer layer) {
_selectedLayer = layer;
notifyListeners();
}
// Download control
Future<void> startDownload() async {
if (_isDownloading || _polygons.isEmpty) return;
_isDownloading = true;
_progress = const DownloadProgress();
_tileOverlays.clear();
notifyListeners();
_downloadService = TileDownloadService();
final stream = _downloadService!.downloadTiles(
polygons: _polygons,
minZoom: _minZoom,
maxZoom: _maxZoom,
urlTemplate: _selectedLayer.urlTemplate,
displayName: _selectedLayer.name,
);
await for (final event in stream) {
switch (event) {
case TileDownloadStarted(:final totalTiles):
_progress = DownloadProgress(total: totalTiles);
notifyListeners();
case TileDownloaded(:final north, :final south, :final east, :final west):
_progress = DownloadProgress(
downloaded: _progress.downloaded + 1,
skipped: _progress.skipped,
failed: _progress.failed,
total: _progress.total,
);
_addOverlay(TileOverlay(
north: north, south: south, east: east, west: west,
));
notifyListeners();
case TileSkipped():
_progress = DownloadProgress(
downloaded: _progress.downloaded,
skipped: _progress.skipped + 1,
failed: _progress.failed,
total: _progress.total,
);
notifyListeners();
case TileBatchSkipped(:final count):
_progress = DownloadProgress(
downloaded: _progress.downloaded,
skipped: _progress.skipped + count,
failed: _progress.failed,
total: _progress.total,
);
notifyListeners();
case TileFailed():
_progress = DownloadProgress(
downloaded: _progress.downloaded,
skipped: _progress.skipped,
failed: _progress.failed + 1,
total: _progress.total,
);
notifyListeners();
case TileDownloadComplete():
_isDownloading = false;
notifyListeners();
case TileDownloadCancelled():
_isDownloading = false;
notifyListeners();
}
}
_isDownloading = false;
_downloadService?.dispose();
_downloadService = null;
notifyListeners();
}
void cancelDownload() {
_downloadService?.cancel();
}
void clearOverlays() {
_tileOverlays.clear();
notifyListeners();
}
void _addOverlay(TileOverlay overlay) {
_tileOverlays.add(overlay);
// Limit overlays to prevent OOM
if (_tileOverlays.length > 500) {
_tileOverlays.removeRange(0, _tileOverlays.length - 500);
}
}
// Cache management
Future<void> refreshCacheSize() async {
_cacheSizeBytes = await _cache.getCacheSize();
notifyListeners();
}
Future<void> deleteStyle(StyleInfo style) async {
if (_coverageStyle?.hash == style.hash) hideCoverage();
await _cache.deleteStyle(style.hash);
await refreshCacheSize();
await refreshLocalStyles();
}
Future<void> clearCache() async {
hideCoverage();
await _cache.clearCache();
_cacheSizeBytes = 0;
_localStyles = [];
notifyListeners();
}
// Coverage overlay — show cached tile bounds on the map
/// Show the coverage of a cached style on the map.
/// Loads tile coordinates from the manifest and converts to bounds.
Future<void> showCoverage(StyleInfo style) async {
if (_coverageStyle?.hash == style.hash) {
// Toggle off if same style tapped again
hideCoverage();
return;
}
_coverageStyle = style;
_coverageOverlays = [];
notifyListeners();
final manifest = await _cache.loadManifest(style.hash);
// Convert manifest keys to tile bound overlays
final overlays = <TileOverlay>[];
for (final key in manifest) {
final parts = key.split('/');
if (parts.length != 3) continue;
final z = int.tryParse(parts[0]);
final x = int.tryParse(parts[1]);
final y = int.tryParse(parts[2]);
if (z == null || x == null || y == null) continue;
final bounds = TileMathService.tileBounds(x, y, z);
overlays.add(TileOverlay(
north: bounds.north,
south: bounds.south,
east: bounds.east,
west: bounds.west,
isSkipped: true, // green color
));
}
_coverageOverlays = overlays;
notifyListeners();
}
void hideCoverage() {
_coverageStyle = null;
_coverageOverlays = [];
notifyListeners();
}
// Sharing controls
Future<void> toggleServer() async {
if (_isServerRunning) {
await _sharing.stopServer();
_isServerRunning = false;
} else {
await _sharing.startServer();
_isServerRunning = _sharing.isRunning;
}
notifyListeners();
}
Future<void> startPeerDiscovery() async {
_peersSubscription?.cancel();
_peersSubscription = _sharing.peersStream.listen((peers) {
_discoveredPeers = peers;
notifyListeners();
});
await _sharing.startDiscovery();
}
Future<void> stopPeerDiscovery() async {
_peersSubscription?.cancel();
_peersSubscription = null;
await _sharing.stopPeerDiscovery();
_discoveredPeers = {};
notifyListeners();
}
void addManualPeer(String ipAddress) {
_sharing.addManualPeer(ipAddress);
_discoveredPeers = _sharing.discoveredPeers;
notifyListeners();
}
void removePeer(TilePeer peer) {
_sharing.removePeer(peer);
_discoveredPeers = _sharing.discoveredPeers;
notifyListeners();
}
// Peer catalog & P2P sync
/// Refresh local style info.
Future<void> refreshLocalStyles() async {
_localStyles = await _cache.listStylesDetailed();
notifyListeners();
}
/// Fetch catalogs from all discovered peers to see what they have.
Future<void> refreshPeerCatalogs() async {
_isFetchingCatalogs = true;
notifyListeners();
_peerCatalogs = await _sharing.fetchAllPeerCatalogs();
_isFetchingCatalogs = false;
notifyListeners();
}
/// Sync a style from one or more peers that have it.
/// Finds all peers offering [styleHash] and pulls missing tiles.
Future<void> syncStyleFromPeers(StyleInfo style) async {
if (_isSyncing) return;
// Find all peers that have this style
final peersWithStyle = <TilePeer>[];
for (final catalog in _peerCatalogs) {
if (catalog.styles.any((s) => s.hash == style.hash)) {
peersWithStyle.add(catalog.peer);
}
}
if (peersWithStyle.isEmpty) return;
_isSyncing = true;
_syncStatus = 'Starting sync of ${style.displayName}...';
_syncProgress = 0;
notifyListeners();
final stream = _sharing.syncStyleFromPeers(
peers: peersWithStyle,
styleHash: style.hash,
styleMeta: style,
);
await for (final event in stream) {
switch (event) {
case PeerSyncStarted(:final totalTiles):
_syncStatus = 'Syncing ${style.displayName}: 0/$totalTiles tiles';
_syncProgress = 0;
notifyListeners();
case PeerSyncTileDownloaded(:final downloaded, :final total):
_syncStatus =
'Syncing ${style.displayName}: $downloaded/$total tiles';
_syncProgress = total > 0 ? downloaded / total : 0;
notifyListeners();
case PeerSyncTileSkipped(:final skipped, :final total):
_syncProgress = total > 0 ? skipped / total : 0;
notifyListeners();
case PeerSyncComplete(:final downloaded, :final skipped, :final failed):
_syncStatus =
'Done! $downloaded new, $skipped cached, $failed failed';
_isSyncing = false;
notifyListeners();
await refreshCacheSize();
await refreshLocalStyles();
case PeerSyncCancelled():
_syncStatus = 'Sync cancelled';
_isSyncing = false;
notifyListeners();
}
}
}
void cancelSync() {
_sharing.cancelSync();
}
// Presets — load a previously downloaded region for quick re-download
/// Load a saved download region as the current selection.
/// Restores polygons, zoom range, and map layer.
void loadPreset(StyleInfo style) {
if (style.region == null) return;
final region = style.region!;
// Restore polygons
_polygons.clear();
for (final polyData in region.polygons) {
final poly = polyData.map((v) => LatLng(v[0], v[1])).toList();
if (poly.length >= 3) _polygons.add(poly);
}
// Restore zoom range
_minZoom = region.minZoom;
_maxZoom = region.maxZoom;
// Try to find the matching map layer
if (style.urlTemplate.isNotEmpty) {
final matchingLayer = MapLayer.allLayers.where(
(l) => l.urlTemplate == style.urlTemplate,
);
if (matchingLayer.isNotEmpty) {
_selectedLayer = matchingLayer.first;
}
}
_currentVertices = [];
_rectangleFirstCorner = null;
_drawingMode = DrawingMode.none;
notifyListeners();
}
@override
void dispose() {
_downloadSubscription?.cancel();
_downloadService?.dispose();
_peersSubscription?.cancel();
super.dispose();
}
}

View File

@@ -12,14 +12,43 @@ import 'contacts_provider.dart';
enum SensorRefreshState { idle, refreshing, success, timeout, unavailable }
class SensorHistorySample {
final DateTime timestamp;
final Map<String, double> values;
const SensorHistorySample({required this.timestamp, required this.values});
factory SensorHistorySample.fromJson(Map<String, dynamic> json) {
final rawValues = json['values'] as Map<String, dynamic>? ?? const {};
return SensorHistorySample(
timestamp: DateTime.fromMillisecondsSinceEpoch(
(json['timestampMillis'] as num?)?.toInt() ?? 0,
),
values: rawValues.map(
(key, value) => MapEntry(key, (value as num).toDouble()),
),
);
}
Map<String, dynamic> toJson() => {
'timestampMillis': timestamp.millisecondsSinceEpoch,
'values': values,
};
}
class SensorsProvider with ChangeNotifier {
static const Duration _successStateRetention = Duration(minutes: 1);
static const Duration selfAutoRefreshInterval = Duration(seconds: 30);
static const String _watchedSensorsKey = 'watched_sensor_keys';
static const String _visibleSensorMetricsKey = 'visible_sensor_metrics';
static const String _fieldSpanKey = 'sensor_field_spans';
static const String _metricLabelKey = 'sensor_metric_labels';
static const String _metricOrderKey = 'sensor_metric_order';
static const String _autoRefreshMinutesKey = 'sensor_auto_refresh_minutes';
static const String _historyKey = 'sensor_history_v1';
static const String _telemetrySourceChannelPrefix = '__source_channel:';
static const String _rawTelemetryHexKey = '__raw_lpp_hex';
static const int _maxHistorySamplesPerSensor = 288;
static const List<int> supportedAutoRefreshIntervals = <int>[
0,
5,
@@ -61,6 +90,8 @@ class SensorsProvider with ChangeNotifier {
<String, List<String>>{};
final Map<String, int> _autoRefreshMinutesBySensor = <String, int>{};
final Map<String, DateTime> _lastRefreshAttemptAt = <String, DateTime>{};
final Map<String, List<SensorHistorySample>> _historyBySensor =
<String, List<SensorHistorySample>>{};
bool _isLoaded = false;
bool _isRefreshingAll = false;
bool _isRunningAutoRefreshTick = false;
@@ -90,6 +121,7 @@ class SensorsProvider with ChangeNotifier {
final storedAutoRefreshJson = prefs.getString(
_key(_autoRefreshMinutesKey),
);
final storedHistoryJson = prefs.getString(_key(_historyKey));
_watchedSensorKeys
..clear()
..addAll(stored);
@@ -101,6 +133,7 @@ class SensorsProvider with ChangeNotifier {
_metricOrderBySensor.clear();
_autoRefreshMinutesBySensor.clear();
_lastRefreshAttemptAt.clear();
_historyBySensor.clear();
if (storedMetricsJson != null && storedMetricsJson.isNotEmpty) {
final decoded = jsonDecode(storedMetricsJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
@@ -145,6 +178,21 @@ class SensorsProvider with ChangeNotifier {
}
}
}
if (storedHistoryJson != null && storedHistoryJson.isNotEmpty) {
final decoded = jsonDecode(storedHistoryJson) as Map<String, dynamic>;
for (final entry in decoded.entries) {
final rawSamples = entry.value as List<dynamic>? ?? const [];
final samples = rawSamples
.whereType<Map<String, dynamic>>()
.map(SensorHistorySample.fromJson)
.where((sample) => sample.values.isNotEmpty)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
if (samples.isNotEmpty) {
_historyBySensor[entry.key] = samples;
}
}
}
_autoRefreshMinutesBySensor.removeWhere(
(key, _) => !_watchedSensorKeys.contains(key),
);
@@ -246,6 +294,24 @@ class SensorsProvider with ChangeNotifier {
}
}
Future<void> _persistHistory() async {
try {
final prefs = await SharedPreferences.getInstance();
final encoded = <String, dynamic>{};
for (final entry in _historyBySensor.entries) {
if (entry.value.isEmpty) {
continue;
}
encoded[entry.key] = entry.value
.map((sample) => sample.toJson())
.toList(growable: false);
}
await prefs.setString(_key(_historyKey), jsonEncode(encoded));
} catch (e) {
debugPrint('Error saving sensor history: $e');
}
}
Set<String> visibleFieldsFor(String publicKeyHex) => Set<String>.unmodifiable(
_visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields,
);
@@ -311,6 +377,11 @@ class SensorsProvider with ChangeNotifier {
int autoRefreshMinutesFor(String publicKeyHex) =>
_autoRefreshMinutesBySensor[publicKeyHex] ?? 0;
List<SensorHistorySample> historyFor(String publicKeyHex) =>
List<SensorHistorySample>.unmodifiable(
_historyBySensor[publicKeyHex] ?? const <SensorHistorySample>[],
);
Future<void> setAutoRefreshMinutes(String publicKeyHex, int minutes) async {
final normalizedMinutes = _normalizeAutoRefreshMinutes(minutes);
final currentMinutes = autoRefreshMinutesFor(publicKeyHex);
@@ -373,6 +444,20 @@ class SensorsProvider with ChangeNotifier {
return List<String>.unmodifiable(dueKeys);
}
bool _isRefreshDueForInterval(
String publicKeyHex, {
required Duration interval,
required DateTime refreshTime,
}) {
if (interval <= Duration.zero) {
return false;
}
final lastRefreshAt = _lastRefreshAttemptAt[publicKeyHex];
return lastRefreshAt == null ||
refreshTime.difference(lastRefreshAt) >= interval;
}
Future<void> toggleMetric(
String publicKeyHex,
String fieldKey,
@@ -474,6 +559,28 @@ class SensorsProvider with ChangeNotifier {
bool isWatched(String publicKeyHex) =>
_watchedSensorKeys.contains(publicKeyHex);
Contact? selfContact(
ContactsProvider contactsProvider,
ConnectionProvider connectionProvider,
) {
final selfKey = connectionProvider.deviceInfo.publicKey;
if (selfKey == null || selfKey.isEmpty) {
return null;
}
final existing = contactsProvider.findContactByKey(
Uint8List.fromList(selfKey),
);
final selfTelemetry = contactsProvider.selfTelemetry;
if (existing != null) {
return selfTelemetry == null
? existing
: existing.copyWith(telemetry: selfTelemetry);
}
return _buildSelfCandidate(connectionProvider, telemetry: selfTelemetry);
}
Future<void> addSensor(Contact contact) async {
if (!contact.isChat && !contact.isRepeater && !contact.isSensor) {
return;
@@ -540,31 +647,128 @@ class SensorsProvider with ChangeNotifier {
_metricOrderBySensor.remove(publicKeyHex);
_autoRefreshMinutesBySensor.remove(publicKeyHex);
_lastRefreshAttemptAt.remove(publicKeyHex);
_historyBySensor.remove(publicKeyHex);
await _persistWatchedSensors();
await _persistVisibleMetrics();
await _persistFieldSpans();
await _persistMetricLabels();
await _persistMetricOrder();
await _persistAutoRefreshMinutes();
await _persistHistory();
notifyListeners();
}
List<Contact> availableCandidates(ContactsProvider contactsProvider) {
Future<void> reorderSensors(int oldIndex, int newIndex) async {
if (_watchedSensorKeys.length < 2) {
return;
}
if (oldIndex < 0 ||
oldIndex >= _watchedSensorKeys.length ||
newIndex < 0 ||
newIndex > _watchedSensorKeys.length) {
return;
}
var targetIndex = newIndex;
if (oldIndex < targetIndex) {
targetIndex -= 1;
}
if (oldIndex == targetIndex) {
return;
}
final movedKey = _watchedSensorKeys.removeAt(oldIndex);
_watchedSensorKeys.insert(targetIndex, movedKey);
await _persistWatchedSensors();
notifyListeners();
}
List<Contact> availableCandidates(
ContactsProvider contactsProvider, {
ConnectionProvider? connectionProvider,
}) {
final candidates = <Contact>[
...contactsProvider.chatContacts,
...contactsProvider.repeaters,
...contactsProvider.sensorContacts,
];
// Add "myself" (own device) as a candidate if it has a public key
if (connectionProvider != null) {
final self = selfContact(contactsProvider, connectionProvider);
if (self != null &&
!candidates.any((c) => c.publicKeyHex == self.publicKeyHex)) {
candidates.insert(0, self);
}
}
candidates.removeWhere((contact) => isWatched(contact.publicKeyHex));
candidates.sort((a, b) => b.lastSeenTime.compareTo(a.lastSeenTime));
return candidates;
}
List<String> displaySensorKeys({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) {
if (_watchedSensorKeys.isNotEmpty) {
return List<String>.unmodifiable(_watchedSensorKeys);
}
final self = selfContact(contactsProvider, connectionProvider);
if (self == null) {
return const <String>[];
}
return <String>[self.publicKeyHex];
}
String? displaySelfKey({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) {
final self = selfContact(contactsProvider, connectionProvider);
if (self == null || _watchedSensorKeys.contains(self.publicKeyHex)) {
return null;
}
return self.publicKeyHex;
}
Contact? contactForDisplay(
String publicKeyHex, {
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) {
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
return entry;
}
}
final self = selfContact(contactsProvider, connectionProvider);
if (self?.publicKeyHex == publicKeyHex) {
return self;
}
return null;
}
Future<void> refreshAll({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) async {
if (_isRefreshingAll || _watchedSensorKeys.isEmpty) {
if (_isRefreshingAll) {
return;
}
final self = selfContact(contactsProvider, connectionProvider);
final keysToRefresh = <String>[
if (self != null) self.publicKeyHex,
..._watchedSensorKeys.where(
(key) => self == null || key != self.publicKeyHex,
),
];
if (keysToRefresh.isEmpty) {
return;
}
@@ -572,7 +776,7 @@ class SensorsProvider with ChangeNotifier {
notifyListeners();
try {
for (final key in _watchedSensorKeys) {
for (final key in keysToRefresh) {
await refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
@@ -597,13 +801,11 @@ class SensorsProvider with ChangeNotifier {
_lastRefreshAttemptAt[publicKeyHex] = requestedAt ?? DateTime.now();
Contact? contact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == publicKeyHex) {
contact = entry;
break;
}
}
final contact = contactForDisplay(
publicKeyHex,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
if (contact == null) {
_setRefreshState(publicKeyHex, SensorRefreshState.unavailable);
@@ -635,7 +837,17 @@ class SensorsProvider with ChangeNotifier {
}
final refreshTime = now ?? DateTime.now();
final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime);
final dueKeys = dueAutoRefreshSensorKeys(now: refreshTime).toList();
final self = selfContact(contactsProvider, connectionProvider);
if (self != null &&
!dueKeys.contains(self.publicKeyHex) &&
_isRefreshDueForInterval(
self.publicKeyHex,
interval: selfAutoRefreshInterval,
refreshTime: refreshTime,
)) {
dueKeys.insert(0, self.publicKeyHex);
}
if (dueKeys.isEmpty) {
return;
}
@@ -655,6 +867,42 @@ class SensorsProvider with ChangeNotifier {
}
}
Future<void> captureTrackedTelemetryHistory({
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) async {
final trackedKeys = <String>{
..._watchedSensorKeys.where((key) => autoRefreshMinutesFor(key) > 0),
};
final self = selfContact(contactsProvider, connectionProvider);
if (self != null && _lastRefreshAttemptAt.containsKey(self.publicKeyHex)) {
trackedKeys.add(self.publicKeyHex);
}
if (trackedKeys.isEmpty) {
return;
}
var changed = false;
for (final key in trackedKeys) {
final contact = contactForDisplay(
key,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
if (contact == null) {
continue;
}
changed = _captureTelemetryHistory(contact) || changed;
}
if (!changed) {
return;
}
await _persistHistory();
notifyListeners();
}
void clearExpiredRefreshStates({DateTime? now}) {
final cutoff = (now ?? DateTime.now()).subtract(_successStateRetention);
final keysToClear = <String>[];
@@ -686,4 +934,125 @@ class SensorsProvider with ChangeNotifier {
_refreshStateUpdatedAt[publicKeyHex] = DateTime.now();
notifyListeners();
}
Contact? _buildSelfCandidate(
ConnectionProvider connectionProvider, {
ContactTelemetry? telemetry,
}) {
final deviceInfo = connectionProvider.deviceInfo;
final selfKey = deviceInfo.publicKey;
if (selfKey == null || selfKey.isEmpty) {
return null;
}
return Contact(
publicKey: Uint8List.fromList(selfKey),
type: ContactType.chat,
flags: 0,
outPathLen: 0,
outPath: Uint8List(64),
advName: deviceInfo.selfName ?? 'My Device',
lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000,
advLat: deviceInfo.advLat ?? 0,
advLon: deviceInfo.advLon ?? 0,
lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000,
telemetry: telemetry,
);
}
bool _captureTelemetryHistory(Contact contact) {
final telemetry = contact.telemetry;
if (telemetry == null) {
return false;
}
final values = _historyValuesForTelemetry(telemetry);
if (values.isEmpty) {
return false;
}
final samples = _historyBySensor.putIfAbsent(
contact.publicKeyHex,
() => <SensorHistorySample>[],
);
final timestamp = telemetry.timestamp;
final existingIndex = samples.lastIndexWhere(
(sample) => sample.timestamp.millisecondsSinceEpoch ==
timestamp.millisecondsSinceEpoch,
);
final nextSample = SensorHistorySample(timestamp: timestamp, values: values);
if (existingIndex >= 0) {
final current = samples[existingIndex];
if (mapEquals(current.values, values)) {
return false;
}
samples[existingIndex] = nextSample;
return true;
}
samples.add(nextSample);
samples.sort((a, b) => a.timestamp.compareTo(b.timestamp));
if (samples.length > _maxHistorySamplesPerSensor) {
samples.removeRange(0, samples.length - _maxHistorySamplesPerSensor);
}
return true;
}
Map<String, double> _historyValuesForTelemetry(ContactTelemetry telemetry) {
final values = <String, double>{};
if (telemetry.batteryMilliVolts != null) {
values['voltage'] = telemetry.batteryMilliVolts! / 1000;
}
if (telemetry.batteryPercentage != null) {
values['battery'] = telemetry.batteryPercentage!;
}
if (telemetry.temperature != null) {
values['temperature'] = telemetry.temperature!;
}
if (telemetry.humidity != null) {
values['humidity'] = telemetry.humidity!;
}
if (telemetry.pressure != null) {
values['pressure'] = telemetry.pressure!;
}
final extraSensorData = telemetry.extraSensorData;
if (extraSensorData != null) {
for (final entry in extraSensorData.entries) {
if (_isTelemetryMetadataKey(entry.key)) {
continue;
}
final numericValue = _historyNumericValue(entry.value);
if (numericValue == null) {
continue;
}
values[_extraFieldKey(entry.key)] = numericValue;
}
}
return values;
}
double? _historyNumericValue(dynamic value) {
if (value is num) {
return value.toDouble();
}
if (value is bool) {
return value ? 1 : 0;
}
return null;
}
bool _isTelemetryMetadataKey(String key) {
return key.startsWith(_telemetrySourceChannelPrefix) ||
key == _rawTelemetryHexKey;
}
String _extraFieldKey(String key) {
return 'extra:$key';
}
}

View File

@@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/contact.dart';
import '../services/profiles_feature_service.dart';
import 'helpers/raw_session_retransmit.dart';
import '../utils/voice_message_parser.dart';
import '../services/voice_codec_service.dart';
@@ -92,6 +93,9 @@ class VoiceProvider with ChangeNotifier {
_restorePersistedVoiceData();
}
String _storageKey() =>
ProfileStorageScope.scopedKey(_voiceSessionsStorageKey);
// ── Session accessors ────────────────────────────────────────────────────
VoiceSession? session(String sessionId) => _sessions[sessionId];
@@ -329,19 +333,22 @@ class VoiceProvider with ChangeNotifier {
}
Future<void> clearStoredVoiceData() async {
_sessions.clear();
_outgoingSessions.clear();
_ignoredIncomingSessions.clear();
_playingSessionId = null;
await _resetInMemoryState();
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_voiceSessionsStorageKey);
await prefs.remove(_storageKey());
} catch (e) {
debugPrint('❌ [VoiceProvider] Failed to clear stored voice data: $e');
}
}
Future<void> reloadProfileScopedState() async {
await _resetInMemoryState();
await _restorePersistedVoiceData();
notifyListeners();
}
Future<void> _persistVoiceData() async {
try {
final prefs = await SharedPreferences.getInstance();
@@ -365,7 +372,7 @@ class VoiceProvider with ChangeNotifier {
)
.toList(),
};
await prefs.setString(_voiceSessionsStorageKey, jsonEncode(payload));
await prefs.setString(_storageKey(), jsonEncode(payload));
} catch (e) {
debugPrint('❌ [VoiceProvider] Failed to persist voice data: $e');
}
@@ -374,7 +381,7 @@ class VoiceProvider with ChangeNotifier {
Future<void> _restorePersistedVoiceData() async {
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_voiceSessionsStorageKey);
final raw = prefs.getString(_storageKey());
if (raw == null || raw.isEmpty) return;
final parsed = jsonDecode(raw) as Map<String, dynamic>;
@@ -437,6 +444,16 @@ class VoiceProvider with ChangeNotifier {
}
}
Future<void> _resetInMemoryState() async {
if (_playingSessionId != null || _player.isPlaying) {
await _player.stop();
}
_sessions.clear();
_outgoingSessions.clear();
_ignoredIncomingSessions.clear();
_playingSessionId = null;
}
@override
void dispose() {
_playerEventsSub.cancel();

View File

@@ -6,7 +6,9 @@ import '../providers/connection_provider.dart';
import '../l10n/app_localizations.dart';
class AddContactScreen extends StatefulWidget {
const AddContactScreen({super.key});
final String? initialAdvert;
const AddContactScreen({super.key, this.initialAdvert});
@override
State<AddContactScreen> createState() => _AddContactScreenState();
@@ -21,7 +23,12 @@ class _AddContactScreenState extends State<AddContactScreen> {
@override
void initState() {
super.initState();
_loadClipboardIfPresent();
if (widget.initialAdvert != null &&
widget.initialAdvert!.trim().isNotEmpty) {
_advertController.text = widget.initialAdvert!.trim();
} else {
_loadClipboardIfPresent();
}
}
@override
@@ -79,9 +86,9 @@ class _AddContactScreenState extends State<AddContactScreen> {
final text = clipboardData?.text;
if (text == null || text.trim().isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.clipboardIsEmpty)),
);
return;
}
@@ -140,9 +147,9 @@ class _AddContactScreenState extends State<AddContactScreen> {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.contactImported)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.contactImported)),
);
setState(() {
_importSucceeded = true;
});
@@ -265,7 +272,7 @@ class _AddContactScreenState extends State<AddContactScreen> {
},
decoration: InputDecoration(
labelText: 'Contact advert',
hintText: 'Paste a share link or hex advert',
hintText: AppLocalizations.of(context)!.pasteShareLinkOrHexAdvert,
alignLabelWithHint: true,
border: const OutlineInputBorder(),
errorText: _validationError,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,10 +9,12 @@ import '../providers/contacts_provider.dart';
import '../services/mesh_map_nodes_service.dart';
import '../widgets/compact_signal_indicator.dart' show SignalMetric;
enum _DiscoveryMenuAction { repeaters, sensors }
enum _DiscoveryListFilter { all, repeaters, sensors, others }
class DiscoveryScreen extends StatefulWidget {
const DiscoveryScreen({super.key});
final bool autoDiscoverRepeatersOnOpen;
const DiscoveryScreen({super.key, this.autoDiscoverRepeatersOnOpen = false});
@override
State<DiscoveryScreen> createState() => _DiscoveryScreenState();
@@ -23,26 +25,30 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
static const int _sensorAdvertType = 4;
final Set<String> _resolvingAdvertKeys = <String>{};
final Set<int> _runningDiscoveryTypes = <int>{};
final TextEditingController _searchController = TextEditingController();
bool _isResolvingAll = false;
bool _hasQueuedAutoRepeaterDiscovery = false;
String _searchQuery = '';
_DiscoveryListFilter _selectedFilter = _DiscoveryListFilter.all;
late final Future<List<MeshMapNode>> _cachedNodesFuture;
@override
void initState() {
super.initState();
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
);
_cachedNodesFuture = MeshMapNodesService.loadCachedNodes();
if (widget.autoDiscoverRepeatersOnOpen) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_autoDiscoverRepeaters();
});
}
}
Future<void> _handleMenuAction(_DiscoveryMenuAction action) async {
switch (action) {
case _DiscoveryMenuAction.repeaters:
await _discoverNodeType(_repeaterAdvertType);
break;
case _DiscoveryMenuAction.sensors:
await _discoverNodeType(_sensorAdvertType);
break;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _clearAllDiscoveries() async {
@@ -61,7 +67,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
@@ -80,7 +86,9 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries)),
SnackBar(
content: Text(AppLocalizations.of(context)!.clearedPendingDiscoveries),
),
);
}
@@ -96,9 +104,10 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
advertType: advertType,
);
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
final label = switch (advertType) {
_repeaterAdvertType => 'Repeater discovery sent',
_sensorAdvertType => 'Sensor discovery sent',
_repeaterAdvertType => l10n.repeaterDiscoverySent,
_sensorAdvertType => l10n.sensorDiscoverySent,
_ => 'Discovery sent',
};
ScaffoldMessenger.of(
@@ -113,6 +122,14 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
}
}
Future<void> _autoDiscoverRepeaters() async {
if (_hasQueuedAutoRepeaterDiscovery) {
return;
}
_hasQueuedAutoRepeaterDiscovery = true;
await _discoverNodeType(_repeaterAdvertType);
}
Future<void> _resolveAdvert(PendingAdvert advert) async {
final keyHex = advert.publicKeyHex;
if (_resolvingAdvertKeys.contains(keyHex)) return;
@@ -237,7 +254,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
if (addError != null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add contact: $addError')),
SnackBar(content: Text(AppLocalizations.of(context)!.failedToAddContact(addError.toString()))),
);
}
return false;
@@ -293,8 +310,8 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
};
}
String? _typeLabelForAdvert(PendingAdvert advert) {
return switch (advert.typeValue) {
String? _typeLabelForValue(int? typeValue) {
return switch (typeValue) {
_repeaterAdvertType => 'Repeater',
_sensorAdvertType => 'Sensor',
3 => 'Room',
@@ -303,22 +320,22 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
};
}
String? _resolvedTypeLabelForAdvert(
int? _resolvedTypeValueForAdvert(
PendingAdvert advert,
List<MeshMapNode> cachedNodes,
) {
final directType = _typeLabelForAdvert(advert);
if (directType != null) {
final directType = advert.typeValue;
if (directType != null && directType != 0) {
return directType;
}
for (final node in cachedNodes) {
if (node.publicKey == advert.publicKeyHex.toLowerCase()) {
return switch (node.type) {
1 => 'Repeater',
4 => 'Sensor',
3 => 'Room',
2 => 'Chat',
1 => _repeaterAdvertType,
4 => _sensorAdvertType,
3 => 3,
2 => 1,
_ => null,
};
}
@@ -327,6 +344,81 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
return null;
}
String? _resolvedTypeLabelForAdvert(
PendingAdvert advert,
List<MeshMapNode> cachedNodes,
) {
return _typeLabelForValue(_resolvedTypeValueForAdvert(advert, cachedNodes));
}
bool get _hasActiveInlineFilter =>
_searchQuery.trim().isNotEmpty ||
_selectedFilter != _DiscoveryListFilter.all;
bool _matchesSelectedFilter(
PendingAdvert advert,
List<MeshMapNode> cachedNodes,
) {
if (_selectedFilter == _DiscoveryListFilter.all) {
return true;
}
final resolvedType = _resolvedTypeValueForAdvert(advert, cachedNodes);
return switch (_selectedFilter) {
_DiscoveryListFilter.all => true,
_DiscoveryListFilter.repeaters => resolvedType == _repeaterAdvertType,
_DiscoveryListFilter.sensors => resolvedType == _sensorAdvertType,
_DiscoveryListFilter.others =>
resolvedType != _repeaterAdvertType &&
resolvedType != _sensorAdvertType,
};
}
bool _matchesSearchQuery(
PendingAdvert advert,
ContactsProvider contactsProvider,
List<MeshMapNode> cachedNodes,
) {
final query = _searchQuery.trim().toLowerCase();
if (query.isEmpty) {
return true;
}
final displayName = _displayNameForAdvert(
advert,
contactsProvider,
cachedNodes,
).toLowerCase();
final typeLabel = (_resolvedTypeLabelForAdvert(advert, cachedNodes) ?? '')
.toLowerCase();
return displayName.contains(query) ||
advert.publicKeyHex.toLowerCase().contains(query) ||
advert.shortDisplayKey.toLowerCase().contains(query) ||
typeLabel.contains(query);
}
List<PendingAdvert> _filteredPendingAdverts(
List<PendingAdvert> adverts,
ContactsProvider contactsProvider,
List<MeshMapNode> cachedNodes,
) {
return adverts
.where((advert) => _matchesSelectedFilter(advert, cachedNodes))
.where(
(advert) =>
_matchesSearchQuery(advert, contactsProvider, cachedNodes),
)
.toList();
}
String _summaryTitle({required int totalCount, required int filteredCount}) {
if (filteredCount == totalCount) {
return 'Discovered nodes ($totalCount)';
}
return 'Discovered nodes ($filteredCount/$totalCount)';
}
Widget _buildAdvertTitle(
BuildContext context, {
required String displayName,
@@ -362,193 +454,236 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(
title: Text(l10n.discovery),
actions: [
Consumer<ConnectionProvider>(
builder: (context, connectionProvider, child) {
final isConnected = connectionProvider.deviceInfo.isConnected;
final repeatersBusy = _runningDiscoveryTypes.contains(
_repeaterAdvertType,
);
final sensorsBusy = _runningDiscoveryTypes.contains(
_sensorAdvertType,
);
return PopupMenuButton<_DiscoveryMenuAction>(
tooltip: 'Discovery tools',
onSelected: _handleMenuAction,
itemBuilder: (context) => [
PopupMenuItem<_DiscoveryMenuAction>(
value: _DiscoveryMenuAction.repeaters,
enabled: isConnected && !repeatersBusy,
child: Row(
children: [
repeatersBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.router_outlined),
SizedBox(width: 12),
Text(l10n.discoverRepeaters),
],
),
),
PopupMenuItem<_DiscoveryMenuAction>(
value: _DiscoveryMenuAction.sensors,
enabled: isConnected && !sensorsBusy,
child: Row(
children: [
sensorsBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.sensors_outlined),
SizedBox(width: 12),
Text(l10n.discoverSensors),
],
),
),
],
);
},
),
],
),
appBar: AppBar(title: Text(l10n.discovery)),
body: FutureBuilder<List<MeshMapNode>>(
future: _cachedNodesFuture,
builder: (context, nodesSnapshot) => Consumer2<ContactsProvider, ConnectionProvider>(
builder: (context, contactsProvider, connectionProvider, child) {
final pendingAdverts = contactsProvider.pendingAdverts;
final isConnected = connectionProvider.deviceInfo.isConnected;
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
builder: (context, nodesSnapshot) =>
Consumer2<ContactsProvider, ConnectionProvider>(
builder: (context, contactsProvider, connectionProvider, child) {
final pendingAdverts = contactsProvider.pendingAdverts;
final isConnected = connectionProvider.deviceInfo.isConnected;
final cachedNodes = nodesSnapshot.data ?? const <MeshMapNode>[];
final repeatersBusy = _runningDiscoveryTypes.contains(
_repeaterAdvertType,
);
final sensorsBusy = _runningDiscoveryTypes.contains(
_sensorAdvertType,
);
// Track which pending adverts are already in the contacts list
final resolvedKeySet = <String>{};
for (final advert in pendingAdverts) {
if (contactsProvider.findContactByKey(advert.publicKey) != null) {
resolvedKeySet.add(advert.publicKeyHex);
}
}
// Track which pending adverts are already in the contacts list
final resolvedKeySet = <String>{};
for (final advert in pendingAdverts) {
if (contactsProvider.findContactByKey(advert.publicKey) !=
null) {
resolvedKeySet.add(advert.publicKeyHex);
}
}
final totalCount = pendingAdverts.length;
final totalCount = pendingAdverts.length;
final filteredAdverts = _filteredPendingAdverts(
pendingAdverts,
contactsProvider,
cachedNodes,
);
final filteredCount = filteredAdverts.length;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.person_search),
const SizedBox(width: 12),
Expanded(
child: Text(
'Discovered nodes ($totalCount)',
style: Theme.of(context).textTheme.titleMedium,
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.person_search),
const SizedBox(width: 12),
Expanded(
child: Text(
_summaryTitle(
totalCount: totalCount,
filteredCount: filteredCount,
),
style: Theme.of(
context,
).textTheme.titleMedium,
),
),
],
),
const SizedBox(height: 12),
Text(
'Resolve entries manually so they do not auto-populate contacts.',
style: Theme.of(context).textTheme.bodyMedium,
),
if (totalCount > 0) ...[
const SizedBox(height: 12),
_buildInlineSearchField(context),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildTypeFilterChip(
context,
label: l10n.all,
filter: _DiscoveryListFilter.all,
),
_buildTypeFilterChip(
context,
label: l10n.repeatersFilter,
filter: _DiscoveryListFilter.repeaters,
),
_buildTypeFilterChip(
context,
label: l10n.sensors,
filter: _DiscoveryListFilter.sensors,
),
_buildTypeFilterChip(
context,
label: l10n.others,
filter: _DiscoveryListFilter.others,
),
],
),
],
const SizedBox(height: 14),
LayoutBuilder(
builder: (context, constraints) {
const spacing = 10.0;
final useTwoColumns =
constraints.maxWidth >= 360;
final buttonWidth = useTwoColumns
? (constraints.maxWidth - spacing) / 2
: constraints.maxWidth;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
_buildHeaderActionButton(
width: buttonWidth,
onPressed: isConnected && !repeatersBusy
? () => _discoverNodeType(
_repeaterAdvertType,
)
: null,
icon: repeatersBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.router_outlined),
label: Text(l10n.discoverRepeaters),
),
_buildHeaderActionButton(
width: buttonWidth,
onPressed: isConnected && !sensorsBusy
? () => _discoverNodeType(
_sensorAdvertType,
)
: null,
icon: sensorsBusy
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.sensors_outlined),
label: Text(l10n.discoverSensors),
),
_buildHeaderActionButton(
width: buttonWidth,
onPressed:
isConnected &&
pendingAdverts.isNotEmpty &&
!_isResolvingAll
? () => _resolveAll(pendingAdverts)
: null,
icon: _isResolvingAll
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(
Icons
.download_for_offline_outlined,
),
label: Text(l10n.resolveAll),
),
_buildHeaderActionButton(
width: buttonWidth,
onPressed: pendingAdverts.isNotEmpty
? _clearAllDiscoveries
: null,
icon: const Icon(Icons.clear_all_rounded),
label: Text(l10n.clearAllLabel),
),
],
);
},
),
],
),
const SizedBox(height: 12),
Text(
'Resolve entries manually so they do not auto-populate contacts.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
Row(
),
),
const SizedBox(height: 16),
if (totalCount == 0)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed:
isConnected &&
pendingAdverts.isNotEmpty &&
!_isResolvingAll
? () => _resolveAll(pendingAdverts)
: null,
icon: _isResolvingAll
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(
Icons.download_for_offline_outlined,
),
label: Text(l10n.resolveAll),
),
Icon(
Icons.person_search_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
onPressed: pendingAdverts.isNotEmpty
? _clearAllDiscoveries
: null,
icon: Icon(Icons.clear_all_rounded),
label: Text(l10n.clearAllLabel),
),
const SizedBox(height: 16),
Text(
'No discovered nodes',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Use the discovery actions above to find repeaters and sensors on the mesh.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
],
),
if (_hasActiveInlineFilter &&
totalCount > 0 &&
filteredCount == 0)
_buildNoFilterResults(context),
...filteredAdverts.map(
(advert) => _buildPendingAdvertCard(
context,
advert: advert,
contactsProvider: contactsProvider,
isConnected: isConnected,
isResolved: resolvedKeySet.contains(
advert.publicKeyHex,
),
cachedNodes: cachedNodes,
l10n: l10n,
),
),
),
),
const SizedBox(height: 16),
if (totalCount == 0)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
children: [
Icon(
Icons.person_search_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
const SizedBox(height: 16),
Text(
'No discovered nodes',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Use the menu to discover repeaters and sensors on the mesh.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
...pendingAdverts.map((advert) =>
_buildPendingAdvertCard(
context,
advert: advert,
contactsProvider: contactsProvider,
isConnected: isConnected,
isResolved: resolvedKeySet.contains(advert.publicKeyHex),
cachedNodes: cachedNodes,
l10n: l10n,
),
),
],
);
},
),
],
);
},
),
),
);
}
@@ -563,7 +698,11 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
required AppLocalizations l10n,
}) {
final isResolving = _resolvingAdvertKeys.contains(advert.publicKeyHex);
final displayName = _displayNameForAdvert(advert, contactsProvider, cachedNodes);
final displayName = _displayNameForAdvert(
advert,
contactsProvider,
cachedNodes,
);
final typeLabel = _resolvedTypeLabelForAdvert(advert, cachedNodes);
final downMetric = SignalMetric.fromValues(
rssiDbm: advert.rxRssiDbm,
@@ -634,7 +773,7 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.person_add_alt_1),
tooltip: 'Resolve contact',
tooltip: AppLocalizations.of(context)!.resolveContact,
onPressed: isConnected
? () => _resolveAdvert(advert)
: null,
@@ -652,6 +791,180 @@ class _DiscoveryScreenState extends State<DiscoveryScreen> {
);
}
Widget _buildInlineSearchField(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final hasFilter = _searchQuery.trim().isNotEmpty;
return Material(
color: Colors.transparent,
child: Ink(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: hasFilter
? colorScheme.primary.withValues(alpha: 0.38)
: colorScheme.outline.withValues(alpha: 0.32),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.025),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
height: 42,
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 12, right: 8),
child: Icon(
Icons.search_rounded,
size: 17,
color: hasFilter
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
Expanded(
child: TextField(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
cursorColor: colorScheme.primary,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
height: 1.1,
),
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.searchDiscoveredNodes,
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(
alpha: 0.85,
),
),
filled: true,
fillColor: Colors.transparent,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
),
),
),
if (hasFilter)
Padding(
padding: const EdgeInsets.only(right: 6),
child: Material(
color: colorScheme.primary.withValues(alpha: 0.10),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
Icons.close_rounded,
size: 14,
color: colorScheme.primary,
),
),
),
),
),
],
),
),
),
),
);
}
Widget _buildTypeFilterChip(
BuildContext context, {
required String label,
required _DiscoveryListFilter filter,
}) {
final scheme = Theme.of(context).colorScheme;
final selected = _selectedFilter == filter;
final color = selected ? scheme.primary : scheme.outline;
return InkWell(
onTap: () {
if (_selectedFilter == filter) {
return;
}
setState(() {
_selectedFilter = filter;
});
},
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: selected ? 0.14 : 0.08),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: color.withValues(alpha: selected ? 0.45 : 0.22),
),
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: color,
),
),
),
);
}
Widget _buildNoFilterResults(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
child: Text(
'No matches',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
);
}
Widget _buildHeaderActionButton({
required double width,
required VoidCallback? onPressed,
required Widget icon,
required Widget label,
}) {
return SizedBox(
width: width,
child: OutlinedButton.icon(
onPressed: onPressed,
icon: icon,
label: label,
),
);
}
Widget _buildSignalSummary(
BuildContext context, {
SignalMetric? downMetric,

View File

@@ -8,9 +8,10 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:vibration/vibration.dart';
import '../providers/connection_provider.dart';
import '../providers/app_provider.dart';
import '../models/device_info.dart' show ConnectionMode;
import '../models/device_info.dart' show ConnectionMode, DeviceInfo;
import '../providers/messages_provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/sensors_provider.dart';
import '../theme/app_theme.dart';
import 'messages_tab.dart';
import 'contacts_tab.dart';
@@ -23,13 +24,11 @@ import 'device_config_screen.dart';
import 'packet_log_screen.dart';
import 'live_traffic_screen.dart';
import 'profiles_screen.dart';
import 'spectrum_scan_screen.dart';
import '../utils/toast_logger.dart';
import '../l10n/app_localizations.dart';
import '../widgets/permission_request_dialog.dart';
import '../widgets/connection_dialog.dart';
import '../utils/battery_display_helper.dart';
import '../services/developer_mode_service.dart';
import '../services/mesh_map_nodes_service.dart';
import '../services/profile_device_key_resolver.dart';
import '../services/profile_manager.dart';
@@ -68,12 +67,12 @@ class _HomeScreenState extends State<HomeScreen>
int _currentIndex = 0;
bool _isMapFullscreen = false;
bool _showRxTxIndicators = true;
bool _isDeveloperModeEnabled = false;
bool _isMapEnabled = true;
bool _isContactsEnabled = true;
bool _isSensorsEnabled = false;
AppLifecycleState _lifecycleState = AppLifecycleState.resumed;
String? _lastProfileDeviceKey;
Timer? _sensorAutoRefreshTicker;
List<_HomeTab> get _enabledTabs {
return [
@@ -107,7 +106,6 @@ class _HomeScreenState extends State<HomeScreen>
// Initialize synchronously so first build always has a valid controller.
_initTabController();
_loadRxTxPreference();
_loadDeveloperModePreference();
MeshMapNodesService.syncInBackgroundIfStale();
// Show permission dialog after the first frame if needed
@@ -120,6 +118,7 @@ class _HomeScreenState extends State<HomeScreen>
WidgetsBinding.instance.addPostFrameCallback((_) {
_handleConnectionProviderChanged();
});
_configureSensorAutoRefreshTicker();
}
void _initTabController() {
@@ -186,6 +185,7 @@ class _HomeScreenState extends State<HomeScreen>
if (!_isMapEnabled) {
_isMapFullscreen = false;
}
_configureSensorAutoRefreshTicker();
final newTabs = _enabledTabs;
final newIndex = newTabs.indexOf(oldTab);
@@ -256,6 +256,7 @@ class _HomeScreenState extends State<HomeScreen>
@override
void dispose() {
_sensorAutoRefreshTicker?.cancel();
_connectionProvider.removeListener(_handleConnectionProviderChanged);
WidgetsBinding.instance.removeObserver(this);
_appProvider.setFastLocationUiActive(false);
@@ -273,11 +274,50 @@ class _HomeScreenState extends State<HomeScreen>
void didChangeAppLifecycleState(AppLifecycleState state) {
_lifecycleState = state;
_syncFastLocationUiState();
_configureSensorAutoRefreshTicker();
if (state == AppLifecycleState.resumed) {
MeshMapNodesService.syncInBackgroundIfStale();
}
}
void _configureSensorAutoRefreshTicker() {
_sensorAutoRefreshTicker?.cancel();
if (!_isSensorsEnabled || _lifecycleState != AppLifecycleState.resumed) {
_sensorAutoRefreshTicker = null;
return;
}
unawaited(_runSensorAutoRefreshTick());
_sensorAutoRefreshTicker = Timer.periodic(
SensorsProvider.selfAutoRefreshInterval,
(_) {
unawaited(_runSensorAutoRefreshTick());
},
);
}
Future<void> _runSensorAutoRefreshTick() async {
if (!mounted ||
!_isSensorsEnabled ||
_lifecycleState != AppLifecycleState.resumed) {
return;
}
final sensorsProvider = context.read<SensorsProvider>();
final contactsProvider = context.read<ContactsProvider>();
final connectionProvider = context.read<ConnectionProvider>();
sensorsProvider.clearExpiredRefreshStates();
await sensorsProvider.refreshDueSensors(
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
now: DateTime.now(),
);
await sensorsProvider.captureTrackedTelemetryHistory(
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
}
Future<void> _loadRxTxPreference() async {
final prefs = await SharedPreferences.getInstance();
if (mounted) {
@@ -291,14 +331,6 @@ class _HomeScreenState extends State<HomeScreen>
}
}
Future<void> _loadDeveloperModePreference() async {
final isEnabled = await DeveloperModeService.isEnabled();
if (!mounted) return;
setState(() {
_isDeveloperModeEnabled = isEnabled;
});
}
void _showPermissionDialog() {
if (!mounted) return;
@@ -434,6 +466,163 @@ class _HomeScreenState extends State<HomeScreen>
);
}
void _showDeviceInfoSheet(BuildContext context, DeviceInfo deviceInfo) {
// Request self telemetry so it's fresh
context.read<ConnectionProvider>().requestSelfTelemetry();
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final telemetry = contactsProvider.selfTelemetry;
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Theme.of(context).dividerColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
Text(
deviceInfo.selfName ?? deviceInfo.deviceName ?? 'Device',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
_deviceInfoRow(
context,
Icons.bluetooth,
'BLE Signal',
deviceInfo.signalRssi != null
? '${deviceInfo.signalRssi} dBm'
: 'N/A',
),
if (deviceInfo.batteryPercent != null)
_deviceInfoRow(
context,
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
'Battery',
'${deviceInfo.batteryPercent!.round()}%',
),
if (deviceInfo.batteryMilliVolts != null)
_deviceInfoRow(
context,
Icons.bolt,
'Voltage',
'${(deviceInfo.batteryMilliVolts! / 1000).toStringAsFixed(2)}V',
),
if (deviceInfo.storageUsedKb != null &&
deviceInfo.storageTotalKb != null)
_deviceInfoRow(
context,
Icons.storage,
'Storage',
'${deviceInfo.storageUsedKb} / ${deviceInfo.storageTotalKb} KB',
),
if (deviceInfo.firmwareVersion != null)
_deviceInfoRow(
context,
Icons.system_update,
'Firmware',
'v${deviceInfo.firmwareVersion}',
),
if (deviceInfo.radioFreq != null)
_deviceInfoRow(
context,
Icons.radio,
'Frequency',
'${(deviceInfo.radioFreq! / 1000).toStringAsFixed(3)} MHz',
),
if (deviceInfo.txPower != null)
_deviceInfoRow(
context,
Icons.power,
'TX Power',
'${deviceInfo.txPower} dBm',
),
if (telemetry != null) ...[
const Divider(height: 24),
Text(
'Self Telemetry',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
if (telemetry.temperature != null)
_deviceInfoRow(
context,
Icons.thermostat,
'Temperature',
'${telemetry.temperature!.toStringAsFixed(1)}°C',
),
if (telemetry.humidity != null)
_deviceInfoRow(
context,
Icons.water_drop,
'Humidity',
'${telemetry.humidity!.toStringAsFixed(1)}%',
),
if (telemetry.pressure != null)
_deviceInfoRow(
context,
Icons.compress,
'Pressure',
'${telemetry.pressure!.toStringAsFixed(1)} hPa',
),
if (telemetry.gpsLocation != null)
_deviceInfoRow(
context,
Icons.gps_fixed,
'GPS',
'${telemetry.gpsLocation!.latitude.toStringAsFixed(5)}, ${telemetry.gpsLocation!.longitude.toStringAsFixed(5)}',
),
],
],
),
),
);
},
),
);
}
Widget _deviceInfoRow(
BuildContext context,
IconData icon,
String label,
String value,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
),
Text(
value,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
],
),
);
}
Widget _buildMiniSignalBars({required int activeBars, required Color color}) {
final inactive = Colors.grey.withValues(alpha: 0.3);
return Row(
@@ -592,12 +781,10 @@ class _HomeScreenState extends State<HomeScreen>
}
}
void _showConnectionDialog(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
Future<void> _showConnectionDialog(BuildContext context) async {
await showConnectionDialogFlow(
context,
backgroundColor: Colors.transparent,
builder: (context) => const ConnectionDialog(),
);
}
@@ -654,32 +841,6 @@ class _HomeScreenState extends State<HomeScreen>
.read<ProfileManager>()
.profilesEnabled;
if (_isDeveloperModeEnabled) {
items.add(
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.radar),
SizedBox(width: 8),
Text(AppLocalizations.of(context)!.spectrumScan),
],
),
onTap: () {
final navigator = Navigator.of(context);
Future.delayed(Duration.zero, () {
if (!mounted) return;
navigator.push(
MaterialPageRoute(
builder: (context) =>
const SpectrumScanScreen(),
),
);
});
},
),
);
}
items.add(
PopupMenuItem(
child: Row(
@@ -797,7 +958,6 @@ class _HomeScreenState extends State<HomeScreen>
),
);
_loadRxTxPreference();
_loadDeveloperModePreference();
});
},
),
@@ -1061,53 +1221,57 @@ class _HomeScreenState extends State<HomeScreen>
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isTcpConnected
? Icons.wifi_rounded
: Icons.bluetooth_connected_rounded,
size: 13,
color: signalColor,
),
if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4),
_buildMiniSignalBars(
activeBars:
BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!,
),
GestureDetector(
onTap: () =>
_showDeviceInfoSheet(context, deviceInfo),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isTcpConnected
? Icons.wifi_rounded
: Icons.bluetooth_connected_rounded,
size: 13,
color: signalColor,
),
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
if (!isTcpConnected &&
deviceInfo.signalRssi != null) ...[
const SizedBox(width: 4),
_buildMiniSignalBars(
activeBars:
BatteryDisplayHelper.getSignalBars(
deviceInfo.signalRssi!,
),
color: signalColor,
),
size: 13,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
const SizedBox(width: 2),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
],
if (deviceInfo.batteryPercent != null) ...[
const SizedBox(width: 8),
Icon(
BatteryDisplayHelper.getBatteryIcon(
deviceInfo.batteryPercent!,
),
size: 13,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
const SizedBox(width: 2),
Text(
'${deviceInfo.batteryPercent!.round()}%',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color:
BatteryDisplayHelper.getBatteryColor(
deviceInfo.batteryPercent!,
),
),
),
],
],
],
),
),
],
),

View File

@@ -3,17 +3,19 @@ import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/ble_packet_log.dart';
import '../models/contact.dart';
import '../providers/channels_provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/connection_provider.dart';
import '../services/live_traffic_summary.dart';
import '../services/location_tracking_service.dart';
import '../services/route_hash_preferences.dart';
import '../services/traffic_stats_reporting_service.dart';
import '../utils/log_rx_route_decoder.dart';
import '../widgets/compact_signal_indicator.dart';
import '../widgets/messages/message_trace_sheet.dart';
import 'packet_log_screen.dart';
import '../l10n/app_localizations.dart';
@@ -27,7 +29,6 @@ T? _maybeProvider<T>(BuildContext context) {
class LiveTrafficScreen extends StatefulWidget {
final List<BlePacketLog> Function() logReader;
final int Function()? rxCountReader;
final Listenable? refreshListenable;
final DateTime Function() now;
final VoidCallback? openPacketLogs;
@@ -35,7 +36,6 @@ class LiveTrafficScreen extends StatefulWidget {
const LiveTrafficScreen({
super.key,
required this.logReader,
this.rxCountReader,
this.refreshListenable,
DateTime Function()? now,
this.openPacketLogs,
@@ -49,7 +49,6 @@ class LiveTrafficScreen extends StatefulWidget {
return LiveTrafficScreen(
key: key,
logReader: () => provider.bleService.packetLogs,
rxCountReader: () => provider.rxPacketCount,
refreshListenable: provider,
openPacketLogs: openPacketLogs,
);
@@ -118,15 +117,17 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
@override
Widget build(BuildContext context) {
final logs = widget.logReader();
final totalRxDataCount = LiveTrafficSummary.countRxDataLogs(logs);
final unfilteredSnapshot = LiveTrafficSummary.fromLogs(
widget.logReader(),
logs,
now: widget.now(),
clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize,
window: _selectedWindow,
);
final snapshot = LiveTrafficSummary.fromLogs(
widget.logReader(),
logs,
now: widget.now(),
clearedAt: _clearedAt,
preferredHashSize: _preferredHashSize,
@@ -153,16 +154,26 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
if (widget.openPacketLogs != null)
IconButton(
onPressed: widget.openPacketLogs,
tooltip: 'Open packet logs',
tooltip: AppLocalizations.of(context)!.openPacketLogs,
icon: const Icon(Icons.list_alt_rounded),
),
IconButton(
onPressed: _openStatsDashboard,
tooltip: AppLocalizations.of(context)!.viewPublicStats,
icon: const Icon(Icons.open_in_new),
),
IconButton(
onPressed: () => _showPacketTypeHelpSheet(context),
tooltip: AppLocalizations.of(context)!.packetTypeHelp,
icon: const Icon(Icons.help_outline),
),
IconButton(
onPressed: () {
setState(() {
_clearedAt = widget.now();
});
},
tooltip: 'Clear live view',
tooltip: AppLocalizations.of(context)!.clearLiveView,
icon: const Icon(Icons.cleaning_services_outlined),
),
],
@@ -177,7 +188,7 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
children: [
_SummaryPanel(
snapshot: snapshot,
totalRxCount: widget.rxCountReader?.call(),
totalRxCount: totalRxDataCount,
routeHashCounts: routeHashCounts,
onWindowTap: () => _showWindowPicker(context),
),
@@ -251,6 +262,13 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
);
}
Future<void> _openStatsDashboard() async {
final url = TrafficStatsReportingService.dashboardUri;
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
}
Future<void> _showWindowPicker(BuildContext context) async {
final selected = await showModalBottomSheet<Duration>(
context: context,
@@ -279,6 +297,68 @@ class _LiveTrafficScreenState extends State<LiveTrafficScreen> {
_selectedWindow = selected;
});
}
Future<void> _showPacketTypeHelpSheet(BuildContext context) {
final packetTypes = LiveTrafficEntry.knownPayloadTypes;
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (context) {
final scheme = Theme.of(context).colorScheme;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: ListView(
shrinkWrap: true,
children: [
Text(
'Packet Types',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'Descriptions below follow the current MeshCore payload definitions.',
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 16),
for (final packetType in packetTypes) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
packetType.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
packetType.description,
style: TextStyle(
fontSize: 13,
color: scheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(height: 10),
],
],
),
),
);
},
);
}
}
class _SummaryPanel extends StatelessWidget {
@@ -306,8 +386,48 @@ class _SummaryPanel extends StatelessWidget {
LiveTrafficBusyness.active => 'Active',
LiveTrafficBusyness.busy => 'Busy',
};
final metrics = [
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:rxPackets'),
label: AppLocalizations.of(context)!.rxPackets,
value: '${snapshot.rxCount}',
subtitle: totalRxCount == null
? _windowSummaryLabel(snapshot.windowDuration)
: 'Device total $totalRxCount',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:rssi'),
label: AppLocalizations.of(context)!.rssi,
value: snapshot.latestRssiDbm == null
? 'No RX data'
: '${snapshot.latestRssiDbm} dBm',
subtitle: snapshot.avgRssiDbm == null
? 'No average yet'
: 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:snr'),
label: AppLocalizations.of(context)!.snr,
value: snapshot.latestSnrDb == null
? 'No RX data'
: '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB',
subtitle: snapshot.avgSnrDb == null
? 'No average yet'
: 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB',
),
_MetricTile(
tileKey: const ValueKey('liveTrafficMetric:multihop'),
label: AppLocalizations.of(context)!.multihop,
value: '${snapshot.multiHopCount}',
subtitle: routeHashCounts.summaryLabel,
footer: snapshot.avgHopCount == null
? 'No routes yet'
: 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops',
),
];
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -346,44 +466,26 @@ class _SummaryPanel extends StatelessWidget {
],
),
const SizedBox(height: 14),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
_MetricTile(
label: AppLocalizations.of(context)!.rxPackets,
value: '${snapshot.rxCount}',
subtitle: totalRxCount == null
? _windowSummaryLabel(snapshot.windowDuration)
: 'Device total $totalRxCount',
),
_MetricTile(
label: AppLocalizations.of(context)!.rssi,
value: snapshot.latestRssiDbm == null
? 'No RX data'
: '${snapshot.latestRssiDbm} dBm',
subtitle: snapshot.avgRssiDbm == null
? 'No average yet'
: 'Avg ${snapshot.avgRssiDbm!.toStringAsFixed(1)} dBm',
),
_MetricTile(
label: AppLocalizations.of(context)!.snr,
value: snapshot.latestSnrDb == null
? 'No RX data'
: '${snapshot.latestSnrDb!.toStringAsFixed(1)} dB',
subtitle: snapshot.avgSnrDb == null
? 'No average yet'
: 'Avg ${snapshot.avgSnrDb!.toStringAsFixed(1)} dB',
),
_MetricTile(
label: AppLocalizations.of(context)!.multihop,
value: '${snapshot.multiHopCount}',
subtitle: routeHashCounts.summaryLabel,
footer: snapshot.avgHopCount == null
? 'No routes yet'
: 'Avg ${snapshot.avgHopCount!.toStringAsFixed(1)} hops',
),
],
LayoutBuilder(
builder: (context, constraints) {
const spacing = 10.0;
const minTileWidth = 150.0;
final columns =
((constraints.maxWidth + spacing) / (minTileWidth + spacing))
.floor()
.clamp(1, metrics.length);
final tileWidth =
(constraints.maxWidth - (spacing * (columns - 1))) / columns;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: [
for (final metric in metrics)
SizedBox(width: tileWidth, child: metric),
],
);
},
),
],
),
@@ -525,12 +627,14 @@ String _windowSummaryLabel(Duration duration) {
}
class _MetricTile extends StatelessWidget {
final Key? tileKey;
final String label;
final String value;
final String subtitle;
final String? footer;
const _MetricTile({
this.tileKey,
required this.label,
required this.value,
required this.subtitle,
@@ -540,7 +644,7 @@ class _MetricTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: 160,
key: tileKey,
height: 136,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@@ -653,14 +757,17 @@ class _LiveTrafficCard extends StatelessWidget {
final accent = isRx ? Colors.green : Colors.blue;
final rxInfo = log.logRxDataInfo;
final originDistance = _originDistanceLabel(context, entry);
final packetDetails = _LiveTrafficPacketDetails.fromEntry(entry);
final channelsProvider = _maybeProvider<ChannelsProvider>(context);
final packetDetails = _LiveTrafficPacketDetails.fromEntry(
entry,
channelsProvider: channelsProvider,
);
final signalMetric = SignalMetric.fromRxInfo(rxInfo);
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showPacketBytesSheet(context, log.rawData),
onLongPress: () => _showTraceSheet(context, entry),
borderRadius: BorderRadius.circular(18),
child: Container(
padding: const EdgeInsets.all(14),
@@ -707,7 +814,7 @@ class _LiveTrafficCard extends StatelessWidget {
if (entry.payloadMeaning != null)
Text(
entry.payloadMeaning!,
maxLines: 1,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
@@ -877,7 +984,7 @@ class _LiveTrafficCard extends StatelessWidget {
style: TextStyle(color: scheme.onSurfaceVariant),
),
const SizedBox(height: 16),
Text('Hex', style: Theme.of(context).textTheme.titleSmall),
Text(AppLocalizations.of(context)!.hex, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Container(
width: double.infinity,
@@ -897,7 +1004,7 @@ class _LiveTrafficCard extends StatelessWidget {
),
),
const SizedBox(height: 16),
Text('ASCII', style: Theme.of(context).textTheme.titleSmall),
Text(AppLocalizations.of(context)!.ascii, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
Container(
width: double.infinity,
@@ -925,31 +1032,6 @@ class _LiveTrafficCard extends StatelessWidget {
);
}
static Future<void> _showTraceSheet(
BuildContext context,
LiveTrafficEntry entry,
) {
final route = entry.route;
if (route == null || route.pathBytes.isEmpty) {
return Future.value();
}
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => MessageTraceSheet.packetPath(
packetPath: route.pathBytes,
descriptionOverride:
'Relay path from packet path bytes (${route.hopHashes.length} hop${route.hopHashes.length == 1 ? '' : 's'})',
noRelayMatchTextOverride:
'No named nodes could be matched for this packet path.',
),
);
}
}
class _LiveTrafficPacketDetails {
@@ -967,27 +1049,22 @@ class _LiveTrafficPacketDetails {
required this.endpointLine,
});
factory _LiveTrafficPacketDetails.fromEntry(LiveTrafficEntry entry) {
factory _LiveTrafficPacketDetails.fromEntry(
LiveTrafficEntry entry, {
ChannelsProvider? channelsProvider,
}) {
final route = entry.route;
final payloadType = route?.payloadType;
final parsedPayload = _ParsedTrafficPayload.tryParse(
entry.log.rawData,
route,
channelsProvider: channelsProvider,
);
final title = switch (payloadType) {
0x00 => 'FLOOD REQUEST',
0x01 => 'FLOOD RESPONSE',
0x02 => 'FLOOD TEXT',
0x03 => 'FLOOD ACK',
0x04 => 'FLOOD ADVERTISEMENT',
0x05 => 'FLOOD GROUP_TEXT',
0x06 => 'FLOOD GROUP_DATA',
0x07 => 'FLOOD ANON_REQUEST',
0x08 => 'FLOOD RETURNED_PATH',
0x09 => 'FLOOD TRACE_PATH',
0x0A => 'FLOOD MULTIPART',
0x0B => 'FLOOD CONTROL',
_ => entry.payloadLabel.toUpperCase(),
0x05 => parsedPayload?.channelDisplayName ?? LiveTrafficEntry.payloadTypeTitle(0x05),
0x06 => parsedPayload?.channelDisplayName ?? LiveTrafficEntry.payloadTypeTitle(0x06),
null => entry.payloadLabel.toUpperCase(),
_ => LiveTrafficEntry.payloadTypeTitle(payloadType),
};
final hopHashes = route?.hopHashes ?? const <String>[];
@@ -1019,13 +1096,15 @@ class _LiveTrafficPacketDetails {
class _ParsedTrafficPayload {
final String? endpointLine;
final String? channelDisplayName;
const _ParsedTrafficPayload({this.endpointLine});
const _ParsedTrafficPayload({this.endpointLine, this.channelDisplayName});
static _ParsedTrafficPayload? tryParse(
List<int> rawData,
DecodedLogRxRoute? route,
) {
DecodedLogRxRoute? route, {
ChannelsProvider? channelsProvider,
}) {
if (rawData.length < 5 ||
rawData.first != LiveTrafficSummary.logRxDataResponseCode) {
return null;
@@ -1062,9 +1141,18 @@ class _ParsedTrafficPayload {
case 0x05:
case 0x06:
if (payload.isEmpty) return const _ParsedTrafficPayload();
final channelHash = payload.first;
final channelHashHex = channelHash
.toRadixString(16)
.padLeft(2, '0')
.toUpperCase();
final channelDisplayName = channelsProvider
?.getUniqueChannelDisplayNameByHashByte(channelHash);
return _ParsedTrafficPayload(
endpointLine:
'Channel Hash: ${payload.first.toRadixString(16).padLeft(2, '0').toUpperCase()}',
channelDisplayName: channelDisplayName,
endpointLine: channelDisplayName == null
? 'Channel Hash: $channelHashHex'
: 'Channel: $channelDisplayName ($channelHashHex)',
);
case 0x00:
case 0x01:
@@ -1105,7 +1193,6 @@ class _PacketInfoLine extends StatelessWidget {
}
}
class _GeoPoint {
final double latitude;
final double longitude;

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
@@ -10,6 +11,7 @@ import 'package:provider/provider.dart';
import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../services/compass_support.dart';
import '../utils/slovenian_crs.dart';
import '../providers/contacts_provider.dart';
import '../providers/messages_provider.dart';
@@ -27,7 +29,6 @@ import '../services/background_location_service.dart';
import '../services/location_tracking_service.dart';
import '../services/map_marker_service.dart';
import '../services/message_destination_preferences.dart';
import '../services/trail_color_service.dart';
import '../services/profiles_feature_service.dart';
import '../widgets/map_debug_info.dart';
import '../widgets/map/compass_widget.dart';
@@ -42,6 +43,8 @@ import '../widgets/messages/sar_update_sheet.dart';
import '../utils/key_comparison.dart';
import '../utils/sar_message_parser.dart';
import '../l10n/app_localizations.dart';
import '../services/offline_map_caching_provider.dart';
import 'offline_map_screen.dart';
class MapTab extends StatefulWidget {
final Function(bool)? onFullscreenChanged;
@@ -60,9 +63,11 @@ class MapTab extends StatefulWidget {
class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
final MapController _mapController = MapController();
static final TileProvider _tileProvider = NetworkTileProvider(
cachingProvider: BuiltInMapCachingProvider.getOrCreateInstance(
maxCacheSize: 10_000_000_000,
overrideFreshAge: const Duration(days: 365),
cachingProvider: OfflineMapCachingProvider(
BuiltInMapCachingProvider.getOrCreateInstance(
maxCacheSize: 10_000_000_000,
overrideFreshAge: const Duration(days: 365),
),
),
);
// DO NOT create a new LocationTrackingService instance here
@@ -201,46 +206,64 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
}
void _startCompassTracking() {
if (!CompassSupport.isAvailable) {
return;
}
final compassStream = FlutterCompass.events;
if (compassStream == null) {
return;
}
// Start listening to compass events
_compassStreamSubscription = compassStream.listen((CompassEvent event) {
// Check if widget is disposing, mounted, and event has valid heading
if (_isDisposing || !mounted || event.heading == null) return;
_compassStreamSubscription = compassStream.listen(
(CompassEvent event) {
// Check if widget is disposing, mounted, and event has valid heading
if (_isDisposing || !mounted || event.heading == null) return;
try {
setState(() {
_compassHeading = event.heading;
});
try {
setState(() {
_compassHeading = event.heading;
});
// Rotate map if rotation mode is enabled and we have compass heading
// Only rotate if map is ready
if (_rotateMarkerWithHeading &&
event.heading != null &&
_isMapReady &&
!_isDisposing) {
try {
// Use moveAndRotate to set absolute rotation
final camera = _mapController.camera;
_mapController.moveAndRotate(
camera.center,
camera.zoom,
-event.heading!,
);
} catch (e) {
// Map not ready yet, ignore
// Rotate map if rotation mode is enabled and we have compass heading
// Only rotate if map is ready
if (_rotateMarkerWithHeading &&
event.heading != null &&
_isMapReady &&
!_isDisposing) {
try {
// Use moveAndRotate to set absolute rotation
final camera = _mapController.camera;
_mapController.moveAndRotate(
camera.center,
camera.zoom,
-event.heading!,
);
} catch (e) {
// Map not ready yet, ignore
}
}
} catch (e) {
// Widget disposed during setState, ignore
if (!_isDisposing) {
debugPrint('Compass tracking error: $e');
}
}
} catch (e) {
// Widget disposed during setState, ignore
if (!_isDisposing) {
debugPrint('Compass tracking error: $e');
},
onError: (Object error, StackTrace stackTrace) {
if (_isDisposing) {
return;
}
}
});
if (error is MissingPluginException) {
debugPrint('Compass plugin is unavailable on this platform');
return;
}
debugPrint('Compass tracking stream error: $error');
},
);
}
Future<void> _loadSettings() async {
@@ -873,7 +896,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (!hasCustomMap)
ListTile(
leading: Icon(Icons.add_photo_alternate),
title: Text(AppLocalizations.of(context)!.loadFromGallery),
title: Text(
AppLocalizations.of(context)!.loadFromGallery,
),
subtitle: const Text(
'Use a cave map image instead of GPS tiles',
),
@@ -905,7 +930,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
),
ListTile(
leading: Icon(Icons.swap_horizontal_circle),
title: Text(AppLocalizations.of(context)!.replaceImage),
title: Text(
AppLocalizations.of(context)!.replaceImage,
),
subtitle: const Text(
'Pick a different map from the gallery',
),
@@ -941,7 +968,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (customMapConfig.isCalibrated)
ListTile(
leading: Icon(Icons.clear),
title: Text(AppLocalizations.of(context)!.clearScale),
title: Text(
AppLocalizations.of(context)!.clearScale,
),
onTap: () async {
await mapProvider.clearCustomMapCalibration();
if (!context.mounted) return;
@@ -999,7 +1028,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
),
child: DetailedCompassDialog(
initialPosition: _locationService.currentPosition,
initialHeading: _currentHeading,
initialHeading: _compassHeading,
contacts: contacts,
sarMarkers: sarMarkers,
),
@@ -1025,7 +1054,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
),
child: DetailedCompassDialog(
initialPosition: _locationService.currentPosition,
initialHeading: _currentHeading,
initialHeading: _compassHeading,
contacts: contacts,
sarMarkers: sarMarkers,
preSelectedContact: selectedContact,
@@ -1277,9 +1306,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
_stopCustomMapCalibration();
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.customMapScaleSaved)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.customMapScaleSaved),
),
);
}
}
@@ -1419,7 +1450,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.chat_bubble_outline),
title: Text(AppLocalizations.of(context)!.openMessage),
subtitle: Text(AppLocalizations.of(context)!.jumpToTheRelatedSarMessage),
subtitle: Text(
AppLocalizations.of(context)!.jumpToTheRelatedSarMessage,
),
onTap: () async {
Navigator.pop(sheetContext);
await _openSarMarkerMessage(
@@ -1655,7 +1688,11 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.pleaseSelectADestinationToSendSarMarker),
content: Text(
AppLocalizations.of(
context,
)!.pleaseSelectADestinationToSendSarMarker,
),
backgroundColor: Colors.red,
),
);
@@ -1785,7 +1822,9 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.sarMarkerBroadcastToPublicChannel),
content: Text(
AppLocalizations.of(context)!.sarMarkerBroadcastToPublicChannel,
),
backgroundColor: Colors.orange,
duration: Duration(seconds: 2),
),
@@ -1842,7 +1881,7 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to send SAR marker: $e'),
content: Text(AppLocalizations.of(context)!.failedToSendSarMarker(e.toString())),
backgroundColor: Colors.red,
),
);
@@ -2514,52 +2553,6 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
);
},
),
// Contact trail polylines (rendered before user trail and markers)
Consumer<MapProvider>(
builder: (context, mapProvider, _) {
// Determine which contacts to show trails for
final contactsToShow = mapProvider.showAllContactTrails
? contactsWithLocation // Show all when master toggle is ON
: contactsWithLocation.where(
(contact) => mapProvider.isContactPathVisible(
contact.publicKeyHex,
),
); // Individual toggles
return PolylineLayer(
polylines: contactsToShow
.where(
(contact) => contact.advertHistory.length >= 2,
)
.map((contact) {
// Use TrailColorService for consistent, emoji-based colors
final color = TrailColorService.getTrailColor(
contact,
);
return Polyline(
points: contact.advertHistory
.map((advert) => advert.location)
.toList(),
color: color.withValues(
alpha: 0.95,
), // More opaque for better visibility
strokeWidth:
4.5, // Thicker for better visibility on all map backgrounds
borderColor: Colors.white.withValues(
alpha: 0.6,
), // Stronger border contrast
borderStrokeWidth: 2.0, // Wider border
// DASHED pattern to distinguish from solid user trail
pattern: StrokePattern.dashed(
segments: [8, 4],
),
);
})
.toList(),
);
},
),
// Location trail layer (rendered after paths, before drawings)
const LocationTrailLayer(),
],
@@ -2604,7 +2597,8 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
context: context,
mapRotation: _getMapRotation(),
userPosition: _locationService.currentPosition,
estimatedLocations: contactsProvider.estimatedLocations,
estimatedLocations:
contactsProvider.estimatedLocations,
onTap: (contact) {
_showDetailedCompassWithContact(
context,
@@ -3239,6 +3233,17 @@ class _MapTabState extends State<MapTab> with AutomaticKeepAliveClientMixin {
child: const Icon(Icons.layers),
),
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'offline_maps',
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const OfflineMapScreen(),
),
),
child: const Icon(Icons.download_for_offline),
),
const SizedBox(height: 8),
FloatingActionButton.small(
heroTag: 'fullscreen_toggle',
onPressed: () {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,843 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../models/map_layer.dart';
import '../providers/offline_tiles_provider.dart';
import '../widgets/map/polygon_draw_handler.dart';
/// Screen for downloading offline map tiles.
///
/// Allows drawing polygons/rectangles to select areas, choosing zoom levels,
/// and downloading tiles with real-time progress visualization.
class OfflineMapScreen extends StatefulWidget {
const OfflineMapScreen({super.key});
@override
State<OfflineMapScreen> createState() => _OfflineMapScreenState();
}
class _OfflineMapScreenState extends State<OfflineMapScreen> {
final MapController _mapController = MapController();
late OfflineTilesProvider _provider;
double _currentZoom = 8;
@override
void initState() {
super.initState();
_provider = context.read<OfflineTilesProvider>();
_provider.refreshCacheSize();
_provider.refreshLocalStyles();
_provider.startPeerDiscovery();
}
@override
void dispose() {
_provider.stopPeerDiscovery();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.offlineMaps),
actions: [
Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return IconButton(
icon: Badge(
isLabelVisible: provider.discoveredPeers.isNotEmpty,
label: Text('${provider.discoveredPeers.length}'),
child: Icon(
provider.isServerRunning
? Icons.wifi_tethering
: Icons.wifi_tethering_off,
),
),
tooltip: provider.isServerRunning
? 'Sharing tiles'
: 'Tile sharing off',
onPressed: () => _showSharingSheet(context, provider),
);
},
),
Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return PopupMenuButton<MapLayer>(
icon: const Icon(Icons.layers),
tooltip: AppLocalizations.of(context)!.mapStyle,
onSelected: (layer) => provider.setSelectedLayer(layer),
itemBuilder: (_) => [
for (final layer in MapLayer.allLayers)
PopupMenuItem(
value: layer,
child: Row(
children: [
if (layer.type == provider.selectedLayer.type)
const Icon(Icons.check, size: 18)
else
const SizedBox(width: 18),
const SizedBox(width: 8),
Text(layer.name),
],
),
),
],
);
},
),
],
),
body: Stack(
children: [
Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: const LatLng(46.05, 14.5), // Slovenia
initialZoom: 8,
onPositionChanged: (camera, hasGesture) {
if (camera.zoom != _currentZoom) {
setState(() => _currentZoom = camera.zoom);
}
},
onTap: (tapPosition, point) {
if (provider.drawingMode != DrawingMode.none) {
provider.addVertex(point);
}
},
),
children: [
TileLayer(
urlTemplate: provider.selectedLayer.urlTemplate,
userAgentPackageName: 'com.meshcore.sar',
maxZoom: provider.selectedLayer.maxZoom,
),
CoverageLayer(currentZoom: _currentZoom),
const PolygonDrawLayer(),
const DownloadProgressLayer(),
],
);
},
),
const DrawingToolbar(),
Positioned(
left: 16,
top: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
),
],
),
child: Text(
'Z ${_currentZoom.toStringAsFixed(1)}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
),
_buildBottomPanel(),
],
),
);
}
Widget _buildBottomPanel() {
return Positioned(
left: 0,
right: 0,
bottom: 0,
child: Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius:
const BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Saved region presets dropdown
if (!provider.isDownloading &&
provider.localStyles
.any((s) => s.region != null)) ...[
DropdownButtonFormField<StyleInfo>(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.bookmark, size: 20),
labelText: 'Saved regions',
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
border: OutlineInputBorder(),
),
isExpanded: true,
hint: Text(AppLocalizations.of(context)!.loadASavedRegion),
items: provider.localStyles
.where((s) => s.region != null)
.map((style) => DropdownMenuItem(
value: style,
child: Text(
'${style.displayName} '
'(z${style.region!.minZoom}-${style.region!.maxZoom}, '
'${_formatNumber(style.tileCount)} tiles)',
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (style) {
if (style != null) {
provider.loadPreset(style);
_fitMapToPolygons(provider);
}
},
),
const SizedBox(height: 8),
],
// Zoom range
if (!provider.isDownloading) ...[
Row(
children: [
Expanded(
child: _ZoomSelector(
label: AppLocalizations.of(context)!.minZoom,
value: provider.minZoom,
onChanged: provider.setMinZoom,
),
),
const SizedBox(width: 16),
Expanded(
child: _ZoomSelector(
label: AppLocalizations.of(context)!.maxZoom,
value: provider.maxZoom,
onChanged: provider.setMaxZoom,
),
),
],
),
const SizedBox(height: 8),
// Tile estimate
Text(
provider.hasPolygons
? '~${_formatNumber(provider.estimatedTileCount)} tiles'
: 'Draw an area to download',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
'Cache: ${_formatBytes(provider.cacheSizeBytes)}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
// Progress
if (provider.isDownloading) ...[
LinearProgressIndicator(
value: provider.progress.percent,
),
const SizedBox(height: 8),
Text(
'${(provider.progress.percent * 100).toStringAsFixed(1)}% — '
'Downloaded: ${provider.progress.downloaded}, '
'Cached: ${provider.progress.skipped}, '
'Failed: ${provider.progress.failed} / '
'${provider.progress.total}',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
// Download complete summary
if (!provider.isDownloading &&
provider.progress.isComplete) ...[
const SizedBox(height: 4),
Text(
'Done! ${provider.progress.downloaded} downloaded, '
'${provider.progress.skipped} cached, '
'${provider.progress.failed} failed',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.green,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 12),
// Action buttons
Row(
children: [
if (!provider.isDownloading) ...[
Expanded(
child: FilledButton.icon(
onPressed: provider.hasPolygons
? () => provider.startDownload()
: null,
icon: const Icon(Icons.download),
label: Text(AppLocalizations.of(context)!.download),
),
),
if (provider.tileOverlays.isNotEmpty) ...[
const SizedBox(width: 8),
IconButton(
onPressed: provider.clearOverlays,
icon: const Icon(Icons.layers_clear),
tooltip: AppLocalizations.of(context)!.clearOverlay,
),
],
if (provider.cacheSizeBytes > 0) ...[
const SizedBox(width: 8),
IconButton(
onPressed: () => _confirmClearCache(provider),
icon: const Icon(Icons.delete_forever),
tooltip: AppLocalizations.of(context)!.clearCache,
),
],
],
if (provider.isDownloading) ...[
Expanded(
child: OutlinedButton.icon(
onPressed: () => provider.cancelDownload(),
icon: const Icon(Icons.cancel),
label: Text(AppLocalizations.of(context)!.cancel),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.red,
),
),
),
],
],
),
],
),
),
),
);
},
),
);
}
void _showSharingSheet(BuildContext context, OfflineTilesProvider provider) {
// Refresh catalogs and local styles when opening
provider.refreshPeerCatalogs();
provider.refreshLocalStyles();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (sheetContext) {
return DraggableScrollableSheet(
initialChildSize: 0.65,
minChildSize: 0.3,
maxChildSize: 0.9,
expand: false,
builder: (context, scrollController) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
return ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [
// Header
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
'Tile Sharing',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
// Server toggle
SwitchListTile(
title: Text(AppLocalizations.of(context)!.shareMyTiles),
subtitle: Text(
provider.isServerRunning
? 'Other devices can fetch tiles from this device'
: 'Start serving cached tiles to nearby devices',
),
secondary: Icon(
provider.isServerRunning
? Icons.wifi_tethering
: Icons.wifi_tethering_off,
),
value: provider.isServerRunning,
onChanged: (_) => provider.toggleServer(),
),
// My cached maps
if (provider.localStyles.isNotEmpty) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'My Cached Maps',
style: Theme.of(context).textTheme.titleSmall,
),
),
...provider.localStyles.map((style) {
final isShowing =
provider.coverageStyle?.hash == style.hash;
return ListTile(
dense: true,
leading: const Icon(Icons.map, size: 20),
title: Text(style.displayName),
subtitle: Text(
'${_formatNumber(style.tileCount)} tiles, '
'${_formatBytes(style.sizeBytes)}',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
isShowing
? Icons.visibility
: Icons.visibility_off,
size: 20,
color: isShowing
? Colors.blue
: null,
),
tooltip: isShowing
? 'Hide on map'
: 'Show on map',
onPressed: () {
provider.showCoverage(style);
},
),
IconButton(
icon: const Icon(Icons.delete_outline,
size: 20),
tooltip: AppLocalizations.of(context)!.delete,
onPressed: () => _confirmDeleteStyle(
context, provider, style),
),
],
),
);
}),
],
// Peers section
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
'Nearby Devices',
style: Theme.of(context).textTheme.titleSmall,
),
),
if (provider.isFetchingCatalogs)
const SizedBox(
width: 16,
height: 16,
child:
CircularProgressIndicator(strokeWidth: 2),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: AppLocalizations.of(context)!.refresh,
onPressed: () =>
provider.refreshPeerCatalogs(),
),
IconButton(
icon: const Icon(Icons.add, size: 20),
tooltip: AppLocalizations.of(context)!.addPeerManually,
onPressed: () =>
_showAddPeerDialog(context, provider),
),
],
),
),
if (provider.discoveredPeers.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'No peers found on the local network.\n'
'Make sure other devices have tile sharing enabled.',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
),
// Peer catalogs — show each peer and their available styles
...provider.peerCatalogs.map((catalog) => _buildPeerCard(
context, provider, catalog)),
// Peers without catalogs yet (just discovered, not queried)
...provider.discoveredPeers
.where((peer) => !provider.peerCatalogs
.any((c) => c.peer == peer))
.map((peer) => ListTile(
leading: const Icon(Icons.devices),
title: Text(peer.ipAddress),
subtitle: Text(AppLocalizations.of(context)!.fetchingCatalog),
trailing: IconButton(
icon: const Icon(
Icons.remove_circle_outline,
size: 20),
onPressed: () =>
provider.removePeer(peer),
),
)),
// Sync progress
if (provider.isSyncing || provider.syncStatus.isNotEmpty) ...[
const Divider(),
if (provider.isSyncing)
LinearProgressIndicator(
value: provider.syncProgress > 0
? provider.syncProgress
: null,
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
provider.syncStatus,
style: Theme.of(context).textTheme.bodySmall,
),
),
if (provider.isSyncing)
TextButton(
onPressed: () => provider.cancelSync(),
child: Text(AppLocalizations.of(context)!.cancel),
),
],
),
),
],
],
);
},
);
},
);
},
);
}
Widget _buildPeerCard(
BuildContext context,
OfflineTilesProvider provider,
PeerCatalog catalog,
) {
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.devices, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
catalog.peer.ipAddress,
style: Theme.of(context).textTheme.titleSmall,
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 18),
onPressed: () => provider.removePeer(catalog.peer),
visualDensity: VisualDensity.compact,
),
],
),
if (catalog.styles.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'No cached tiles on this device',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.6),
),
),
),
...catalog.styles.map((style) {
// Check if we already have this style locally
final localMatch = provider.localStyles
.where((s) => s.hash == style.hash);
final localCount =
localMatch.isNotEmpty ? localMatch.first.tileCount : 0;
final missingTiles = style.tileCount - localCount;
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.map_outlined, size: 20),
title: Text(style.displayName),
subtitle: Text(
'${_formatNumber(style.tileCount)} tiles, '
'${_formatBytes(style.sizeBytes)}'
'${localCount > 0 ? ' (you have ${_formatNumberInline(localCount)})' : ''}',
),
trailing: missingTiles > 0
? TextButton.icon(
onPressed: provider.isSyncing
? null
: () => provider.syncStyleFromPeers(style),
icon: const Icon(Icons.download, size: 16),
label: Text(
missingTiles == style.tileCount
? 'Get all'
: '+${_formatNumber(missingTiles)}',
),
)
: const Icon(Icons.check_circle,
color: Colors.green, size: 20),
);
}),
],
),
),
);
}
String _formatNumberInline(int n) {
if (n < 1000) return '$n';
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
return '${(n / 1000000).toStringAsFixed(1)}M';
}
void _fitMapToPolygons(OfflineTilesProvider provider) {
if (provider.polygons.isEmpty) return;
var minLat = 90.0, maxLat = -90.0;
var minLng = 180.0, maxLng = -180.0;
for (final poly in provider.polygons) {
for (final p in poly) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLng) minLng = p.longitude;
if (p.longitude > maxLng) maxLng = p.longitude;
}
}
_mapController.fitCamera(
CameraFit.bounds(
bounds: LatLngBounds(
LatLng(minLat, minLng),
LatLng(maxLat, maxLng),
),
padding: const EdgeInsets.all(50),
),
);
}
void _showAddPeerDialog(
BuildContext context, OfflineTilesProvider provider) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.addPeer),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'IP Address',
hintText: '192.168.1.100',
),
keyboardType: TextInputType.number,
autofocus: true,
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
provider.addManualPeer(value.trim());
Navigator.pop(context);
}
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () {
final ip = controller.text.trim();
if (ip.isNotEmpty) {
provider.addManualPeer(ip);
Navigator.pop(context);
}
},
child: Text(AppLocalizations.of(context)!.add),
),
],
),
);
}
void _confirmDeleteStyle(
BuildContext context,
OfflineTilesProvider provider,
StyleInfo style,
) {
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(AppLocalizations.of(context)!.deleteStyleConfirm(style.displayName)),
content: Text(
'${_formatNumber(style.tileCount)} tiles, '
'${_formatBytes(style.sizeBytes)} will be deleted.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () {
provider.deleteStyle(style);
Navigator.pop(dialogContext);
},
child:
Text(AppLocalizations.of(context)!.delete, style: const TextStyle(color: Colors.red)),
),
],
),
);
}
void _confirmClearCache(OfflineTilesProvider provider) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.clearOfflineCache),
content: Text(
'This will delete ${_formatBytes(provider.cacheSizeBytes)} of cached tiles.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () {
provider.clearCache();
Navigator.pop(context);
},
child:
Text(AppLocalizations.of(context)!.delete, style: const TextStyle(color: Colors.red)),
),
],
),
);
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
String _formatNumber(int n) {
if (n < 1000) return '$n';
if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K';
return '${(n / 1000000).toStringAsFixed(1)}M';
}
}
class _ZoomSelector extends StatelessWidget {
final String label;
final int value;
final ValueChanged<int> onChanged;
const _ZoomSelector({
required this.label,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(label, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(width: 8),
Expanded(
child: Slider(
value: value.toDouble(),
min: 0,
max: 19,
divisions: 19,
label: '$value',
onChanged: (v) => onChanged(v.round()),
),
),
SizedBox(
width: 24,
child: Text(
'$value',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
],
);
}
}

View File

@@ -96,7 +96,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.exportFailed(e.toString()))));
}
}
}
@@ -147,7 +147,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Export failed: $e')));
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context)!.exportFailed(e.toString()))));
}
}
}
@@ -222,7 +222,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
? Icons.arrow_downward
: Icons.arrow_upward,
),
tooltip: 'Filter by direction',
tooltip: AppLocalizations.of(context)!.filterByDirection,
onSelected: (direction) {
setState(() {
_filterDirection = direction;
@@ -314,7 +314,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
// Export menu
PopupMenuButton(
icon: const Icon(Icons.share),
tooltip: 'Export logs',
tooltip: AppLocalizations.of(context)!.exportLogs,
itemBuilder: (context) => [
PopupMenuItem(
value: 'csv',
@@ -348,7 +348,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
// Clear logs
IconButton(
icon: const Icon(Icons.delete_outline),
tooltip: 'Clear logs',
tooltip: AppLocalizations.of(context)!.clearLogs,
onPressed: () => _clearLogs(context),
),
],
@@ -360,7 +360,7 @@ class _PacketLogScreenState extends State<PacketLogScreen> {
padding: const EdgeInsets.all(8.0),
child: TextField(
decoration: InputDecoration(
hintText: 'Search logs...',
hintText: AppLocalizations.of(context)!.searchLogs,
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
@@ -615,7 +615,7 @@ class _PacketLogCard extends StatelessWidget {
const Spacer(),
IconButton(
onPressed: onCopy,
tooltip: 'Copy full hex',
tooltip: AppLocalizations.of(context)!.copyFullHex,
icon: const Icon(Icons.copy_all_rounded, size: 18),
visualDensity: VisualDensity.compact,
),
@@ -750,9 +750,7 @@ class _DecodedRouteSection extends StatelessWidget {
return FutureBuilder<List<dynamic>>(
future: Future.wait<dynamic>([
RouteHashPreferences.getHashSize(),
MeshMapNodesService.loadCachedNodes(
cacheTtl: MeshMapNodesService.traceCacheTtl,
),
MeshMapNodesService.loadCachedNodes(),
]),
builder: (context, snapshot) {
final decodedRoute = LogRxRouteDecoder.decode(

View File

@@ -25,7 +25,7 @@ class ProfilesScreen extends StatelessWidget {
.importProfileFromFile();
},
icon: const Icon(Icons.file_open),
tooltip: 'Import profile',
tooltip: AppLocalizations.of(context)!.importProfile,
),
],
),
@@ -143,9 +143,9 @@ class ProfilesScreen extends StatelessWidget {
child: Text(AppLocalizations.of(context)!.rename),
),
if (!profile.isDefault)
const PopupMenuItem(
PopupMenuItem(
value: 'delete',
child: Text('Delete'),
child: Text(AppLocalizations.of(context)!.delete),
),
],
),
@@ -197,7 +197,7 @@ class ProfilesScreen extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
@@ -231,11 +231,11 @@ class ProfilesScreen extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
child: Text(AppLocalizations.of(context)!.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(controller.text.trim()),
child: const Text('Save'),
child: Text(AppLocalizations.of(context)!.save),
),
],
),

View File

@@ -269,12 +269,12 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
await connectionProvider.getContact(_hexToBytes(repeater.publicKey));
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${repeater.name} added to contacts')),
SnackBar(content: Text(AppLocalizations.of(context)!.addedToContacts(repeater.name))),
);
} catch (error) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to add ${repeater.name}: $error')),
SnackBar(content: Text(AppLocalizations.of(context)!.failedToAddName(repeater.name, error.toString()))),
);
} finally {
if (mounted) {
@@ -374,7 +374,7 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
? null
: () => _loadRepeaters(forceRefresh: true),
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
tooltip: AppLocalizations.of(context)!.refresh,
),
],
),
@@ -491,7 +491,7 @@ class _RepeatersMapScreenState extends State<RepeatersMapScreen> {
),
floatingActionButton: FloatingActionButton(
onPressed: _jumpToMyLocation,
tooltip: 'My location',
tooltip: AppLocalizations.of(context)!.myLocation,
child: const Icon(Icons.my_location),
),
);

View File

@@ -1,12 +1,14 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/contact.dart';
import '../providers/connection_provider.dart';
import '../providers/contacts_provider.dart';
import '../providers/map_provider.dart';
import '../providers/sensors_provider.dart';
import '../widgets/contacts/ping_contact_sheet.dart';
import '../widgets/sensors/bthome_met_history_sheet.dart';
import '../widgets/sensors/sensor_history_sheet.dart';
import '../widgets/sensors/sensor_telemetry_card.dart';
import '../l10n/app_localizations.dart';
@@ -20,150 +22,42 @@ class SensorsTab extends StatefulWidget {
}
class _SensorsTabState extends State<SensorsTab> {
Timer? _minuteTicker;
final Map<String, DateTime> _lastCenteredTelemetryAtBySensor =
<String, DateTime>{};
@override
void initState() {
super.initState();
if (widget.isActive) {
unawaited(_handleMinuteTick());
_scheduleMinuteTicker();
}
}
@override
void dispose() {
_minuteTicker?.cancel();
super.dispose();
}
@override
void didUpdateWidget(covariant SensorsTab oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isActive == widget.isActive) {
return;
}
if (widget.isActive) {
unawaited(_handleMinuteTick());
_scheduleMinuteTicker();
return;
}
_minuteTicker?.cancel();
_minuteTicker = null;
}
void _scheduleMinuteTicker() {
_minuteTicker?.cancel();
if (!widget.isActive) {
return;
}
final now = DateTime.now();
final nextMinute = DateTime(
now.year,
now.month,
now.day,
now.hour,
now.minute + 1,
);
final delay = nextMinute.difference(now);
_minuteTicker = Timer(delay, () {
if (!mounted) return;
unawaited(_handleMinuteTick());
_minuteTicker = Timer.periodic(const Duration(minutes: 1), (_) {
unawaited(_handleMinuteTick());
});
});
}
Future<void> _handleMinuteTick() async {
if (!mounted || !widget.isActive) {
return;
}
final sensorsProvider = context.read<SensorsProvider>();
sensorsProvider.clearExpiredRefreshStates();
await sensorsProvider.refreshDueSensors(
contactsProvider: context.read<ContactsProvider>(),
connectionProvider: context.read<ConnectionProvider>(),
now: DateTime.now(),
);
if (!mounted) {
return;
}
setState(() {});
}
Future<void> _showAddSensorSheet(BuildContext context) async {
final sensorsProvider = context.read<SensorsProvider>();
final contactsProvider = context.read<ContactsProvider>();
final candidates = sensorsProvider.availableCandidates(contactsProvider);
final candidates = sensorsProvider.availableCandidates(
contactsProvider,
connectionProvider: context.read<ConnectionProvider>(),
);
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
if (candidates.isEmpty) {
return const SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No eligible nodes available. Discover a relay or node first.',
),
),
isScrollControlled: true,
builder: (sheetContext) => AddSensorSheet(
candidates: candidates,
onClose: () => Navigator.of(sheetContext).pop(),
onSelect: (contact) async {
await sensorsProvider.addSensor(contact);
if (!sheetContext.mounted) return;
Navigator.of(sheetContext).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.addedToSensors(contact.displayName))),
);
}
return SafeArea(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.only(bottom: 20),
children: [
ListTile(
title: const Text(
'Add sensor node',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(AppLocalizations.of(context)!.pickARelayOrNodeToWatchInSensors),
),
...candidates.map(
(contact) => ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
),
leading: CircleAvatar(
radius: 24,
backgroundColor: const Color(0xFFDDEAF8),
child: Icon(
_typeIcon(contact),
color: const Color(0xFF1E4F7A),
),
),
title: Text(contact.displayName),
subtitle: _SensorCandidatePreview(contact: contact),
isThreeLine: true,
onTap: () async {
await sensorsProvider.addSensor(contact);
if (!sheetContext.mounted) return;
Navigator.of(sheetContext).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${contact.displayName} added to Sensors',
),
),
);
},
),
),
],
),
);
},
},
),
);
}
@@ -174,7 +68,7 @@ class _SensorsTabState extends State<SensorsTab> {
) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (pageContext) => _SensorCustomizeView(
builder: (pageContext) => SensorCustomizeView(
publicKeyHex: publicKeyHex,
initialContact: contact,
onRenameMetric:
@@ -235,14 +129,14 @@ class _SensorsTabState extends State<SensorsTab> {
actions: [
if (sensorsProvider.labelOverrideFor(publicKeyHex, option.key) !=
null)
TextButton(onPressed: controller.clear, child: const Text('Reset')),
TextButton(onPressed: controller.clear, child: Text(AppLocalizations.of(dialogContext)!.reset)),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
child: Text(AppLocalizations.of(dialogContext)!.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Save'),
child: Text(AppLocalizations.of(dialogContext)!.save),
),
],
),
@@ -268,6 +162,65 @@ class _SensorsTabState extends State<SensorsTab> {
);
}
void _maybeCenterMapOnTelemetryUpdate(
Iterable<String> sensorKeys, {
required SensorsProvider sensorsProvider,
required ContactsProvider contactsProvider,
required ConnectionProvider connectionProvider,
}) {
if (!widget.isActive) {
return;
}
Contact? latestContact;
DateTime? latestTimestamp;
for (final key in sensorKeys) {
final contact = sensorsProvider.contactForDisplay(
key,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final timestamp = contact?.telemetry?.timestamp;
final location = contact?.displayLocation;
if (contact == null || timestamp == null || location == null) {
continue;
}
final previousTimestamp = _lastCenteredTelemetryAtBySensor[key];
if (previousTimestamp != null && !timestamp.isAfter(previousTimestamp)) {
continue;
}
if (latestTimestamp == null || timestamp.isAfter(latestTimestamp)) {
latestContact = contact;
latestTimestamp = timestamp;
}
}
if (latestContact == null || latestTimestamp == null) {
return;
}
_lastCenteredTelemetryAtBySensor[latestContact.publicKeyHex] =
latestTimestamp;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !widget.isActive) {
return;
}
final location = latestContact!.displayLocation;
if (location == null) {
return;
}
context.read<MapProvider>().navigateToLocation(
location: location,
zoom: 15.0,
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -275,32 +228,63 @@ class _SensorsTabState extends State<SensorsTab> {
onPressed: () => _showAddSensorSheet(context),
child: const Icon(Icons.add),
),
body: Consumer2<SensorsProvider, ContactsProvider>(
builder: (context, sensorsProvider, contactsProvider, child) {
final watchedKeys = sensorsProvider.watchedSensorKeys;
body: Consumer3<SensorsProvider, ContactsProvider, ConnectionProvider>(
builder:
(
context,
sensorsProvider,
contactsProvider,
connectionProvider,
child,
) {
final watchedKeys = sensorsProvider.watchedSensorKeys;
final hasPersistedSensors = watchedKeys.isNotEmpty;
final selfDisplayKey = sensorsProvider.displaySelfKey(
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final displayKeys = <String>[
...?selfDisplayKey == null ? null : <String>[selfDisplayKey],
...watchedKeys,
];
_maybeCenterMapOnTelemetryUpdate(
displayKeys,
sensorsProvider: sensorsProvider,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final hasAnyCards =
selfDisplayKey != null || watchedKeys.isNotEmpty;
return RefreshIndicator(
onRefresh: () => _refreshAll(context),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
children: [
if (watchedKeys.isEmpty)
const _EmptySensorsState()
else
...watchedKeys.map((key) {
Contact? contact;
for (final entry in contactsProvider.contacts) {
if (entry.publicKeyHex == key) {
contact = entry;
break;
}
}
final availableFieldKeys = sensorMetricKeysFor(contact);
final visibleFields = sensorsProvider
.effectiveVisibleFieldsFor(key, availableFieldKeys);
return SensorTelemetryCard(
Widget buildSensorCard(
String key, {
required int index,
required int totalCount,
required bool isWatchedCard,
}) {
final contact = sensorsProvider.contactForDisplay(
key,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final availableFieldKeys = sensorMetricKeysFor(contact);
final visibleFields = sensorsProvider.effectiveVisibleFieldsFor(
key,
availableFieldKeys,
);
return Padding(
key: ValueKey<String>('sensor_card_$key'),
padding: EdgeInsets.only(
bottom: index == totalCount - 1 ? 0 : 12,
),
child: ReorderableDelayedDragStartListener(
enabled: isWatchedCard,
index: index,
child: SensorTelemetryCard(
contact: contact,
state: sensorsProvider.stateFor(key),
showActionSheetOnTap: true,
visibleFields: visibleFields,
fieldOrder: sensorsProvider.metricOrderFor(
key,
@@ -311,30 +295,273 @@ class _SensorsTabState extends State<SensorsTab> {
for (final field in visibleFields)
field: sensorsProvider.fieldSpanFor(key, field),
},
onRemove: () async {
await sensorsProvider.removeSensor(key);
},
onRemove: isWatchedCard
? () async {
await sensorsProvider.removeSensor(key);
}
: null,
onCustomize: () =>
_showMetricSelector(context, key, contact),
onMetricTap: (fieldKey) async {
final history = sensorsProvider.historyFor(key);
final hasHistoryForField = history.any(
(sample) => sample.values.containsKey(fieldKey),
);
if (!hasHistoryForField) {
return;
}
await showSensorHistorySheet(
context,
publicKeyHex: key,
initialFieldKey: fieldKey,
);
},
onShowMetHistory: (contact) =>
showBTHomeMetHistorySheet(context, contact: contact),
onMoveUp: isWatchedCard && index > 0
? () =>
sensorsProvider.reorderSensors(index, index - 1)
: null,
onMoveDown: isWatchedCard && index < totalCount - 1
? () =>
sensorsProvider.reorderSensors(index, index + 2)
: null,
onRefresh: () => sensorsProvider.refreshSensor(
publicKeyHex: key,
contactsProvider: contactsProvider,
connectionProvider: context.read<ConnectionProvider>(),
connectionProvider: connectionProvider,
),
);
}),
],
),
);
},
onPing: contact == null
? null
: () async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor:
Theme.of(context).colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(16),
),
),
builder: (context) =>
PingContactSheet(contact: contact),
);
},
),
),
);
}
return RefreshIndicator(
onRefresh: () => _refreshAll(context),
child: !hasAnyCards
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
children: const [_EmptySensorsState()],
)
: hasPersistedSensors && selfDisplayKey == null
? ReorderableListView.builder(
buildDefaultDragHandles: false,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
itemCount: watchedKeys.length,
onReorder: (oldIndex, newIndex) =>
sensorsProvider.reorderSensors(oldIndex, newIndex),
itemBuilder: (context, index) => buildSensorCard(
watchedKeys[index],
index: index,
totalCount: watchedKeys.length,
isWatchedCard: true,
),
)
: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 96),
children: [
if (selfDisplayKey != null)
buildSensorCard(
selfDisplayKey,
index: 0,
totalCount: watchedKeys.isEmpty ? 1 : 2,
isWatchedCard: false,
),
if (selfDisplayKey != null && watchedKeys.isEmpty)
_SelfOnlySensorsCta(
onAddSensor: () => _showAddSensorSheet(context),
),
if (watchedKeys.isNotEmpty)
ReorderableListView.builder(
shrinkWrap: true,
buildDefaultDragHandles: false,
physics: const NeverScrollableScrollPhysics(),
itemCount: watchedKeys.length,
onReorder: (oldIndex, newIndex) => sensorsProvider
.reorderSensors(oldIndex, newIndex),
itemBuilder: (context, index) => buildSensorCard(
watchedKeys[index],
index: index,
totalCount: watchedKeys.length,
isWatchedCard: true,
),
),
],
),
);
},
),
);
}
}
class _SensorCustomizeView extends StatelessWidget {
class AddSensorSheet extends StatefulWidget {
final List<Contact> candidates;
final Future<void> Function(Contact contact) onSelect;
final VoidCallback onClose;
const AddSensorSheet({
super.key,
required this.candidates,
required this.onSelect,
required this.onClose,
});
@override
State<AddSensorSheet> createState() => _AddSensorSheetState();
}
class _AddSensorSheetState extends State<AddSensorSheet> {
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.candidates.isEmpty) {
return const SafeArea(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'No eligible nodes available. Discover a relay or node first.',
),
),
);
}
final l10n = AppLocalizations.of(context)!;
final query = _searchController.text.trim().toLowerCase();
final filteredCandidates = widget.candidates.where((contact) {
if (query.isEmpty) {
return true;
}
return contact.displayName.toLowerCase().contains(query) ||
contact.publicKeyHex.toLowerCase().contains(query);
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SizedBox(
height: MediaQuery.of(context).size.height * 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 12, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Add sensor node',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(l10n.pickARelayOrNodeToWatchInSensors),
],
),
),
IconButton(
onPressed: widget.onClose,
tooltip: l10n.close,
icon: const Icon(Icons.close),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
child: TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: AppLocalizations.of(context)!.searchSensors,
border: const OutlineInputBorder(),
),
),
),
Expanded(
child: filteredCandidates.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
child: Align(
alignment: Alignment.topLeft,
child: Text(
'No sensor candidates match your search.',
),
),
)
: ListView.builder(
padding: const EdgeInsets.only(bottom: 20),
itemCount: filteredCandidates.length,
itemBuilder: (context, index) {
final contact = filteredCandidates[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 4,
),
leading: CircleAvatar(
radius: 24,
backgroundColor: const Color(0xFFDDEAF8),
child: Icon(
_typeIcon(contact),
color: const Color(0xFF1E4F7A),
),
),
title: Text(contact.displayName),
subtitle: _SensorCandidatePreview(contact: contact),
isThreeLine: true,
onTap: () => widget.onSelect(contact),
);
},
),
),
],
),
),
),
);
}
}
class SensorCustomizeView extends StatelessWidget {
final String publicKeyHex;
final Contact? initialContact;
final bool showLivePreview;
final Future<void> Function({
required BuildContext context,
required String publicKeyHex,
@@ -343,10 +570,12 @@ class _SensorCustomizeView extends StatelessWidget {
})
onRenameMetric;
const _SensorCustomizeView({
const SensorCustomizeView({
super.key,
required this.publicKeyHex,
required this.initialContact,
required this.onRenameMetric,
this.showLivePreview = true,
});
@override
@@ -386,34 +615,44 @@ class _SensorCustomizeView extends StatelessWidget {
return Scaffold(
appBar: AppBar(
title: Text('Customize ${contact?.displayName ?? 'Sensor'}'),
title: Text(AppLocalizations.of(context)!.customizeSensor(contact?.displayName ?? AppLocalizations.of(context)!.sensor)),
),
body: ListView(
padding: EdgeInsets.fromLTRB(16, 16, 16, 24),
children: [
_SensorCustomizeSectionCard(
title: AppLocalizations.of(context)!.livePreview,
subtitle:
'Changes apply immediately. This card matches the current dashboard layout for this sensor.',
child: SensorTelemetryCard(
contact: contact,
state: sensorsProvider.stateFor(publicKeyHex),
visibleFields: visibleFields,
fieldOrder: sensorsProvider.metricOrderFor(
publicKeyHex,
visibleFields,
if (showLivePreview)
_SensorCustomizeSectionCard(
title: AppLocalizations.of(context)!.livePreview,
subtitle:
'Changes apply immediately. This card matches the current dashboard layout for this sensor.',
child: SensorTelemetryCard(
contact: contact,
state: sensorsProvider.stateFor(publicKeyHex),
visibleFields: visibleFields,
fieldOrder: sensorsProvider.metricOrderFor(
publicKeyHex,
visibleFields,
),
labelOverrides: sensorsProvider.labelOverridesFor(
publicKeyHex,
),
onShowMetHistory: contact == null
? null
: (contact) => showBTHomeMetHistorySheet(
context,
contact: contact,
),
fieldSpans: {
for (final field in visibleFields)
field: sensorsProvider.fieldSpanFor(
publicKeyHex,
field,
),
},
margin: EdgeInsets.zero,
emptyMetricsMessage: 'No telemetry fields available yet.',
),
labelOverrides: sensorsProvider.labelOverridesFor(
publicKeyHex,
),
fieldSpans: {
for (final field in visibleFields)
field: sensorsProvider.fieldSpanFor(publicKeyHex, field),
},
margin: EdgeInsets.zero,
emptyMetricsMessage: 'No telemetry fields available yet.',
),
),
_SensorCustomizeSectionCard(
title: AppLocalizations.of(context)!.refreshSchedule,
subtitle:
@@ -815,12 +1054,12 @@ class SensorMetricSelectorItem extends StatelessWidget {
),
const Spacer(),
IconButton(
tooltip: 'Move up',
tooltip: AppLocalizations.of(context)!.moveUp,
onPressed: canMoveUp ? onMoveUp : null,
icon: const Icon(Icons.arrow_upward),
),
IconButton(
tooltip: 'Move down',
tooltip: AppLocalizations.of(context)!.moveDown,
onPressed: canMoveDown ? onMoveDown : null,
icon: const Icon(Icons.arrow_downward),
),
@@ -876,55 +1115,8 @@ class _EmptySensorsState extends StatefulWidget {
}
class _EmptySensorsStateState extends State<_EmptySensorsState> {
bool _discoveryTriggered = false;
bool _discoveryInProgress = false;
@override
void initState() {
super.initState();
// Auto-trigger sensor discovery when the empty state is shown
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoDiscover();
});
}
Future<void> _autoDiscover() async {
if (_discoveryTriggered || !mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) return;
_discoveryTriggered = true;
setState(() => _discoveryInProgress = true);
try {
await connectionProvider.discoverNodeType(advertType: 4);
} finally {
if (mounted) setState(() => _discoveryInProgress = false);
}
}
Future<void> _discoverSensors() async {
if (_discoveryInProgress || !mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
if (!connectionProvider.deviceInfo.isConnected) return;
setState(() => _discoveryInProgress = true);
try {
await connectionProvider.discoverNodeType(advertType: 4);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.sensorDiscoverySent)),
);
}
} finally {
if (mounted) setState(() => _discoveryInProgress = false);
}
}
@override
Widget build(BuildContext context) {
final isConnected =
context.watch<ConnectionProvider>().deviceInfo.isConnected;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 64),
child: Column(
@@ -951,28 +1143,84 @@ class _EmptySensorsStateState extends State<_EmptySensorsState> {
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text(
'Use + to add discovered relays or nodes. Pull down to refresh telemetry after adding them.',
'Use + to add discovered relays or nodes. Your device will appear here automatically when available.',
textAlign: TextAlign.center,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: isConnected && !_discoveryInProgress
? _discoverSensors
: null,
icon: _discoveryInProgress
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
],
),
);
}
}
class _SelfOnlySensorsCta extends StatelessWidget {
final VoidCallback onAddSensor;
const _SelfOnlySensorsCta({required this.onAddSensor});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.fromLTRB(18, 18, 18, 20),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Icon(
Icons.add_chart_rounded,
color: theme.colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Add another device',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
)
: const Icon(Icons.sensors_outlined),
label: Text(_discoveryInProgress
? 'Discovering...'
: 'Discover Sensors'),
const SizedBox(height: 8),
Text(
'Bring in weather stations, repeaters, or other devices to watch them here.',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.35,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.icon(
onPressed: onAddSensor,
icon: const Icon(Icons.add, size: 18),
label: Text(AppLocalizations.of(context)!.choose),
),
),
],
),

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -221,14 +221,13 @@ class _WelcomeWizardScreenState extends State<WelcomeWizardScreen> {
}
Future<void> _openConnectionDialog() async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const ConnectionDialog(),
final connected = await showConnectionDialogFlow(
context,
offerPostConnectRepeaterDiscovery: true,
);
if (!mounted) return;
final connectionProvider = context.read<ConnectionProvider>();
if (connectionProvider.deviceInfo.isConnected) {
if (connected) {
await connectionProvider.refreshDeviceInfo();
}
if (!mounted) return;

View File

@@ -23,7 +23,6 @@ class AppConfigSnapshotService {
voiceEchoCancellationEnabled: appProvider.isVoiceEchoCancellationEnabled,
voiceNoiseSuppressionEnabled: appProvider.isVoiceNoiseSuppressionEnabled,
messageFontScale: appProvider.messageFontScale,
autoRouteRotationEnabled: appProvider.autoRouteRotationEnabled,
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
voiceBitrate: await VoiceBitratePreferences.getBitrate(),
@@ -98,11 +97,6 @@ class AppConfigSnapshotService {
if (section.messageFontScale != null) {
await appProvider.setMessageFontScale(section.messageFontScale!);
}
if (section.autoRouteRotationEnabled != null) {
await appProvider.toggleAutoRouteRotationEnabled(
section.autoRouteRotationEnabled!,
);
}
if (section.clearPathOnMaxRetry != null) {
await appProvider.toggleClearPathOnMaxRetry(section.clearPathOnMaxRetry!);
}
@@ -144,11 +138,11 @@ class AppConfigSnapshotService {
}
if (section.fastLocationMovementThresholdMeters != null) {
locationTracking.fastLocationMovementThresholdMeters =
section.fastLocationMovementThresholdMeters!;
section.fastLocationMovementThresholdMeters!.clamp(10.0, 1000.0);
}
if (section.fastLocationActiveCadenceSeconds != null) {
locationTracking.fastLocationActiveCadenceSeconds =
section.fastLocationActiveCadenceSeconds!;
section.fastLocationActiveCadenceSeconds!.clamp(10, 31);
}
await locationTracking.saveSettings();
await appProvider.reloadProfileScopedSettings();

View File

@@ -1,5 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/widgets.dart';
import 'package:geolocator/geolocator.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -14,8 +17,13 @@ class BackgroundLocationService {
static const String _prefKeyDistance = 'background_tracking_distance';
static const String _prefKeyLastLat = 'background_last_lat';
static const String _prefKeyLastLon = 'background_last_lon';
static const String _notificationChannelId =
'meshcore_sar_background_tracking';
static const int _notificationId = 9101;
MeshCoreBleService? _bleService;
final FlutterBackgroundService _service = FlutterBackgroundService();
bool _serviceConfigured = false;
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
@@ -33,8 +41,6 @@ class BackgroundLocationService {
/// Start location tracking and automatic advertisement
/// Returns true if successful, false otherwise
///
/// Note: This is foreground tracking. For true background operation,
/// additional platform-specific configuration is required.
Future<bool> startTracking({double distanceThreshold = 10.0}) async {
if (!_isInitialized || _bleService == null) {
debugPrint(
@@ -80,6 +86,8 @@ class BackgroundLocationService {
await prefs.setBool(_scopedKey(_prefKeyEnabled), true);
await prefs.setDouble(_scopedKey(_prefKeyDistance), distanceThreshold);
await _startForegroundService(distanceThreshold);
// Start listening to position updates
Position? lastPosition;
try {
@@ -171,12 +179,87 @@ class BackgroundLocationService {
debugPrint('🛑 [BackgroundLocation] Stopping tracking');
await _positionSubscription?.cancel();
_positionSubscription = null;
await _stopForegroundService();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_scopedKey(_prefKeyEnabled), false);
debugPrint('✅ [BackgroundLocation] Tracking stopped');
}
Future<void> _startForegroundService(double distanceThreshold) async {
if (!Platform.isAndroid && !Platform.isIOS) {
return;
}
try {
if (!_serviceConfigured) {
await _configureForegroundService();
}
final running = await _service.isRunning();
if (!running) {
await _service.startService();
}
_service.invoke('trackingUpdate', {
'distanceThreshold': distanceThreshold,
});
} catch (e) {
debugPrint('⚠️ [BackgroundLocation] Foreground service start failed: $e');
}
}
Future<void> _stopForegroundService() async {
if (!Platform.isAndroid && !Platform.isIOS) {
return;
}
try {
if (await _service.isRunning()) {
_service.invoke('stopService');
}
} catch (e) {
debugPrint('⚠️ [BackgroundLocation] Foreground service stop failed: $e');
}
}
Future<void> _configureForegroundService() async {
const channel = AndroidNotificationChannel(
_notificationChannelId,
'Background tracking',
description:
'Keeps MeshCore SAR location sharing active while the app is in the background.',
importance: Importance.low,
);
final notifications = FlutterLocalNotificationsPlugin();
await notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>()
?.createNotificationChannel(channel);
await _service.configure(
androidConfiguration: AndroidConfiguration(
onStart: meshCoreSarBackgroundServiceStart,
autoStart: false,
autoStartOnBoot: false,
isForegroundMode: true,
notificationChannelId: _notificationChannelId,
initialNotificationTitle: 'MeshCore SAR',
initialNotificationContent: 'Maintaining background tracking',
foregroundServiceNotificationId: _notificationId,
foregroundServiceTypes: [AndroidForegroundType.location],
),
iosConfiguration: IosConfiguration(
autoStart: false,
onForeground: meshCoreSarBackgroundServiceStart,
onBackground: meshCoreSarBackgroundServiceIos,
),
);
_serviceConfigured = true;
}
/// Update the distance threshold for location updates
/// Note: This will restart tracking with the new threshold
Future<void> updateDistanceThreshold(double distance) async {
@@ -212,3 +295,40 @@ class BackgroundLocationService {
);
}
}
@pragma('vm:entry-point')
Future<bool> meshCoreSarBackgroundServiceIos(ServiceInstance service) async {
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
return true;
}
@pragma('vm:entry-point')
void meshCoreSarBackgroundServiceStart(ServiceInstance service) {
DartPluginRegistrant.ensureInitialized();
if (service is AndroidServiceInstance) {
service.setAsForegroundService();
service.setForegroundNotificationInfo(
title: 'MeshCore SAR',
content: 'Maintaining background tracking',
);
}
service.on('trackingUpdate').listen((event) {
if (service is AndroidServiceInstance) {
final threshold = event?['distanceThreshold'];
final suffix = threshold is num
? ' (${threshold.toStringAsFixed(0)} m updates)'
: '';
service.setForegroundNotificationInfo(
title: 'MeshCore SAR',
content: 'Maintaining background tracking$suffix',
);
}
});
service.on('stopService').listen((event) {
service.stopSelf();
});
}

View File

@@ -0,0 +1,178 @@
import '../models/contact.dart';
enum BTHomeMetMeasurement {
temperature(1, 'Temperature', '°C'),
humidity(2, 'Humidity', '%'),
windSpeed(3, 'Wind speed', 'm/s'),
gust(4, 'Wind gust', 'm/s'),
rain(5, 'Rain', 'mm');
const BTHomeMetMeasurement(this.id, this.label, this.unit);
final int id;
final String label;
final String unit;
static BTHomeMetMeasurement? fromId(int id) {
for (final value in BTHomeMetMeasurement.values) {
if (value.id == id) {
return value;
}
}
return null;
}
}
class BTHomeMetHistoryPage {
const BTHomeMetHistoryPage({
required this.measurement,
required this.page,
required this.values,
});
final BTHomeMetMeasurement measurement;
final int page;
final List<double> values;
double? get latest => values.isEmpty ? null : values.last;
double? get minimum => values.isEmpty
? null
: values.reduce((left, right) => left < right ? left : right);
double? get maximum => values.isEmpty
? null
: values.reduce((left, right) => left > right ? left : right);
}
class BTHomeMetHistoryFormatException implements Exception {
const BTHomeMetHistoryFormatException(this.message);
final String message;
@override
String toString() => message;
}
class BTHomeMetHistoryParser {
static BTHomeMetHistoryPage parse(String text) {
final parts = text
.trim()
.split(',')
.map((part) => part.trim())
.toList(growable: false);
if (parts.length < 3) {
throw const BTHomeMetHistoryFormatException(
'MET history response is too short.',
);
}
final measurementId = int.tryParse(parts[0]);
final page = int.tryParse(parts[1]);
final count = int.tryParse(parts[2]);
if (measurementId == null || page == null || count == null) {
throw const BTHomeMetHistoryFormatException(
'MET history header is invalid.',
);
}
final measurement = BTHomeMetMeasurement.fromId(measurementId);
if (measurement == null) {
throw BTHomeMetHistoryFormatException(
'Unsupported MET history measurement id: $measurementId',
);
}
if (count < 0) {
throw const BTHomeMetHistoryFormatException(
'MET history sample count is invalid.',
);
}
if (parts.length != count + 3) {
throw BTHomeMetHistoryFormatException(
'MET history sample count mismatch: expected $count values, got ${parts.length - 3}.',
);
}
final values = <double>[];
for (final part in parts.skip(3)) {
final value = double.tryParse(part);
if (value == null) {
throw BTHomeMetHistoryFormatException(
'Invalid MET history sample value: $part',
);
}
values.add(value);
}
return BTHomeMetHistoryPage(
measurement: measurement,
page: page,
values: List<double>.unmodifiable(values),
);
}
}
bool _isTruthyCapabilityValue(Object? value) {
if (value is num) {
return value > 0;
}
if (value is bool) {
return value;
}
return false;
}
bool _hasBTHomeMetCapability(Contact? contact) {
final extraSensorData = contact?.telemetry?.extraSensorData;
if (extraSensorData == null) {
return false;
}
// MET history is only enabled when the node advertises an explicit
// capability marker on channel 1. Accept a small set of channel-1 marker
// keys so the app remains tolerant while firmware-side encoding settles.
const capabilityKeys = <String>[
'met_capability',
'met_capability_1',
'generic_sensor_1',
'light_level_1',
];
for (final key in capabilityKeys) {
if (_isTruthyCapabilityValue(extraSensorData[key])) {
return true;
}
}
return false;
}
List<BTHomeMetMeasurement> bTHomeMetMeasurementsForContact(Contact? contact) {
final telemetry = contact?.telemetry;
if (telemetry == null || !_hasBTHomeMetCapability(contact)) {
return const <BTHomeMetMeasurement>[];
}
final measurements = <BTHomeMetMeasurement>[
if (telemetry.temperature != null) BTHomeMetMeasurement.temperature,
if (telemetry.humidity != null) BTHomeMetMeasurement.humidity,
];
final extraSensorData = telemetry.extraSensorData;
if (extraSensorData != null) {
if (extraSensorData.keys.any(
(key) => key.startsWith('speed_') || key.startsWith('signed_speed_'),
)) {
measurements.add(BTHomeMetMeasurement.windSpeed);
}
if (extraSensorData.keys.any((key) => key.startsWith('gust_'))) {
measurements.add(BTHomeMetMeasurement.gust);
}
if (extraSensorData.keys.any((key) => key.startsWith('rain_'))) {
measurements.add(BTHomeMetMeasurement.rain);
}
}
return List<BTHomeMetMeasurement>.unmodifiable(measurements);
}
bool supportsBTHomeMetHistory(Contact? contact) =>
bTHomeMetMeasurementsForContact(contact).isNotEmpty;

View File

@@ -88,10 +88,13 @@ class CayenneLppParser {
LatLng? gpsLocation;
double? batteryPercentage;
double? batteryMilliVolts;
double? deferredBatteryMilliVolts;
double? deferredBatteryPercentage;
double? temperature;
double? humidity;
double? pressure;
final extraSensorData = <String, dynamic>{};
bool sawNonSelfChannel = false;
int fieldCount = 0;
while (reader.hasRemaining) {
@@ -112,6 +115,9 @@ class CayenneLppParser {
final channel = reader.readByte();
debugPrint(' Channel: $channel');
if (channel != _selfTelemetryChannel) {
sawNonSelfChannel = true;
}
final type = reader.readByte();
debugPrint(
@@ -136,7 +142,7 @@ class CayenneLppParser {
final value = rawValue / 100.0;
debugPrint(' Analog Input (raw): $rawValue');
debugPrint(' Analog Input (volts): ${value}V');
if (_isBatteryChannel(channel)) {
if (_isDedicatedBatteryChannel(channel)) {
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
extraSensorData[_sourceChannelKey('battery')] = channel;
@@ -144,6 +150,10 @@ class CayenneLppParser {
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
} else if (_isDeferredBatteryChannel(channel)) {
deferredBatteryMilliVolts = value * 1000;
deferredBatteryPercentage = _calculateBatteryPercentage(value);
extraSensorData['analog_input_$channel'] = value;
} else {
extraSensorData['analog_input_$channel'] = value;
}
@@ -237,7 +247,7 @@ class CayenneLppParser {
final value = rawValue / 100.0;
debugPrint(' Voltage (raw): $rawValue');
debugPrint(' Voltage: ${value}V');
if (_isBatteryChannel(channel)) {
if (_isDedicatedBatteryChannel(channel)) {
batteryMilliVolts = value * 1000;
batteryPercentage = _calculateBatteryPercentage(value);
extraSensorData[_sourceChannelKey('battery')] = channel;
@@ -245,6 +255,10 @@ class CayenneLppParser {
debugPrint(
' → Battery: ${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
} else if (_isDeferredBatteryChannel(channel)) {
deferredBatteryMilliVolts = value * 1000;
deferredBatteryPercentage = _calculateBatteryPercentage(value);
extraSensorData['voltage_$channel'] = value;
} else {
extraSensorData['voltage_$channel'] = value;
}
@@ -327,7 +341,7 @@ class CayenneLppParser {
case _lppPercentage:
final value = reader.readByte().toDouble();
debugPrint(' Percentage: $value%');
if (_isBatteryChannel(channel)) {
if (_isDedicatedBatteryChannel(channel) || _isDeferredBatteryChannel(channel)) {
batteryPercentage = value;
extraSensorData[_sourceChannelKey('battery')] = channel;
} else {
@@ -631,6 +645,21 @@ class CayenneLppParser {
}
}
if (batteryMilliVolts == null &&
batteryPercentage == null &&
deferredBatteryMilliVolts != null &&
deferredBatteryPercentage != null &&
!sawNonSelfChannel) {
batteryMilliVolts = deferredBatteryMilliVolts;
batteryPercentage = deferredBatteryPercentage;
extraSensorData[_sourceChannelKey('battery')] = _selfTelemetryChannel;
extraSensorData[_sourceChannelKey('voltage')] = _selfTelemetryChannel;
debugPrint(
' Promoted self-channel voltage to battery: '
'${batteryPercentage.toStringAsFixed(1)}% (${batteryMilliVolts.toStringAsFixed(0)}mV)',
);
}
debugPrint(' Parsed $fieldCount fields');
debugPrint(' ✅ [CayenneLPP] Parsing complete');
debugPrint(
@@ -677,8 +706,10 @@ class CayenneLppParser {
return ((voltage - 3.0) / 1.2) * 100.0;
}
static bool _isBatteryChannel(int channel) =>
channel == 0 || channel == _selfTelemetryChannel;
static bool _isDedicatedBatteryChannel(int channel) => channel == 0;
static bool _isDeferredBatteryChannel(int channel) =>
channel == _selfTelemetryChannel;
static int _readUInt32BE(BufferReader reader) {
final bytes = reader.readBytes(4);

View File

@@ -0,0 +1,11 @@
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
class CompassSupport {
const CompassSupport._();
static bool get isAvailable =>
!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
}

View File

@@ -3,12 +3,136 @@ import '../utils/log_rx_route_decoder.dart';
enum LiveTrafficBusyness { quiet, active, busy }
class LiveTrafficPacketTypeDetails {
final int payloadType;
final String title;
final String label;
final String summary;
final String description;
const LiveTrafficPacketTypeDetails({
required this.payloadType,
required this.title,
required this.label,
required this.summary,
required this.description,
});
}
class LiveTrafficEntry {
final BlePacketLog log;
final DecodedLogRxRoute? route;
const LiveTrafficEntry({required this.log, required this.route});
static const List<LiveTrafficPacketTypeDetails> _knownPayloadTypes = [
LiveTrafficPacketTypeDetails(
payloadType: 0x00,
title: 'FLOOD REQUEST',
label: 'Request',
summary: 'Encrypted request to a known peer',
description:
'Encrypted request to a known peer. The wire payload carries destination and source hashes plus a MAC, and the decrypted body starts with a timestamp followed by application-defined request data such as stats or keepalive requests.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x01,
title: 'FLOOD RESPONSE',
label: 'Response',
summary: 'Encrypted reply to a request',
description:
'Encrypted reply to a Request or Anonymous request. After decryption, the body is application-defined response data with no single generic response envelope.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x02,
title: 'FLOOD TEXT',
label: 'Text message',
summary: 'Encrypted direct text with timestamp and retry flags',
description:
'Encrypted direct text message to a known peer. The decrypted body contains a timestamp, a flags and attempt byte, and the message text.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x03,
title: 'FLOOD ACK',
label: 'Ack',
summary: '4-byte acknowledgement for an earlier message',
description:
'Short acknowledgement proving that a prior message was received. It carries a 4-byte checksum derived from the original message data.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x04,
title: 'FLOOD ADVERTISEMENT',
label: 'Advertisement',
summary: 'Signed node identity broadcast',
description:
'Signed node advertisement announcing a device identity plus app data such as a name or location. Receivers verify the signature before accepting it.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x05,
title: 'FLOOD GROUP_TEXT',
label: 'Group text',
summary: 'Encrypted channel text matched by channel hash',
description:
'Encrypted channel text message. It is matched by the first byte of SHA256(channel secret), then decrypted with the channel key. The plaintext is usually in the form "<sender name>: <message body>".',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x06,
title: 'FLOOD GROUP_DATA',
label: 'Group datagram',
summary: 'Encrypted channel data with type and length',
description:
'Encrypted channel datagram. After channel-hash matching and decryption, the body starts with a 16-bit data type and a 1-byte data length before the application payload.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x07,
title: 'FLOOD ANON_REQUEST',
label: 'Anonymous request',
summary: 'Request using an ephemeral sender key',
description:
'Encrypted request to a destination hash without using a stored sender identity. The packet includes the sender\'s ephemeral public key so the receiver can derive the shared secret.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x08,
title: 'FLOOD RETURNED_PATH',
label: 'Returned path',
summary:
'Return route back to the sender, with optional bundled ACK or response',
description:
'Path reply sent back to the original author to describe the route a received packet took. MeshCore stores that returned path as the peer\'s direct out-path and can bundle an ACK or response in the same payload.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x09,
title: 'FLOOD TRACE_PATH',
label: 'Trace path',
summary: 'Direct trace that records SNR at each hop',
description:
'Direct diagnostic packet that walks a supplied path and appends one SNR sample per hop. When it reaches the end of the path, the initiator can inspect hop-by-hop link quality.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x0A,
title: 'FLOOD MULTIPART',
label: 'Multipart packet',
summary: 'Wrapper for one packet in a multipart sequence',
description:
'Packet wrapper used when a logical message is split into a sequence. Current MeshCore code uses it for multipart ACKs, where the first nibble says how many parts remain.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x0B,
title: 'FLOOD CONTROL',
label: 'Control packet',
summary: 'Discovery or other control data',
description:
'Control or discovery payload, typically unencrypted. Current documented subtypes are discovery request and response packets used to find nearby nodes and report SNR.',
),
LiveTrafficPacketTypeDetails(
payloadType: 0x0F,
title: 'RAW CUSTOM',
label: 'Custom packet',
summary: 'Application-defined custom packet',
description:
'Application-defined raw packet bytes for custom encryption or custom payload formats. MeshCore leaves the inner format up to the higher-level application.',
),
];
bool get isMultiHop => (route?.hopCount ?? 0) > 1;
int? get hopCount => route?.hopCount;
@@ -37,66 +161,39 @@ class LiveTrafficEntry {
.join(' -> ');
}
static String payloadTypeLabel(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request';
case 0x01:
return 'Response';
case 0x02:
return 'Text message';
case 0x03:
return 'Ack';
case 0x04:
return 'Advertisement';
case 0x05:
return 'Group text';
case 0x06:
return 'Group datagram';
case 0x07:
return 'Anonymous request';
case 0x08:
return 'Returned path';
case 0x09:
return 'Trace path';
case 0x0A:
return 'Multipart packet';
case 0x0B:
return 'Control packet';
default:
return '0x${payloadType.toRadixString(16).padLeft(2, '0')}';
static List<LiveTrafficPacketTypeDetails> get knownPayloadTypes =>
_knownPayloadTypes;
static LiveTrafficPacketTypeDetails payloadTypeDetails(int payloadType) {
for (final details in _knownPayloadTypes) {
if (details.payloadType == payloadType) {
return details;
}
}
return LiveTrafficPacketTypeDetails(
payloadType: payloadType,
title: '0x${payloadType.toRadixString(16).padLeft(2, '0').toUpperCase()}',
label: '0x${payloadType.toRadixString(16).padLeft(2, '0')}',
summary: 'Unknown or application-specific protocol payload',
description:
'Unknown or application-specific packet type. Check the current MeshCore firmware or app-specific protocol docs for the exact payload format.',
);
}
static String payloadTypeLabel(int payloadType) {
return payloadTypeDetails(payloadType).label;
}
static String payloadTypeTitle(int payloadType) {
return payloadTypeDetails(payloadType).title;
}
static String payloadTypeMeaning(int payloadType) {
switch (payloadType) {
case 0x00:
return 'Request (destination/source hashes + MAC)';
case 0x01:
return 'Response to Request or Anonymous request';
case 0x02:
return 'Plain text message';
case 0x03:
return 'Simple acknowledgement';
case 0x04:
return 'Node advertisement';
case 0x05:
return 'Unverified group text message';
case 0x06:
return 'Unverified group datagram';
case 0x07:
return 'Generic anonymous request';
case 0x08:
return 'Returned path payload';
case 0x09:
return 'Trace path collecting hop SNR';
case 0x0A:
return 'One packet from a multipart set';
case 0x0B:
return 'Control or discovery packet';
default:
return 'protocol payload';
}
return payloadTypeDetails(payloadType).summary;
}
static String payloadTypeDescription(int payloadType) {
return payloadTypeDetails(payloadType).description;
}
}
@@ -141,6 +238,18 @@ class LiveTrafficSummary {
const LiveTrafficSummary._();
static bool isRxDataLog(BlePacketLog log) {
return log.direction == PacketDirection.rx &&
log.responseCode == logRxDataResponseCode;
}
static int countRxDataLogs(Iterable<BlePacketLog> logs, {DateTime? since}) {
return logs.where((log) {
return isRxDataLog(log) &&
(since == null || !log.timestamp.isBefore(since));
}).length;
}
static LiveTrafficSnapshot fromLogs(
Iterable<BlePacketLog> logs, {
required DateTime now,
@@ -154,15 +263,14 @@ class LiveTrafficSummary {
? clearedAt
: windowStart;
final recentLogs = logs
.where(
(log) =>
log.direction == PacketDirection.rx &&
log.responseCode == logRxDataResponseCode &&
!log.timestamp.isBefore(effectiveStart),
)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final recentLogs =
logs
.where(
(log) =>
isRxDataLog(log) && !log.timestamp.isBefore(effectiveStart),
)
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final entries = <LiveTrafficEntry>[];
for (final log in recentLogs) {
@@ -215,7 +323,9 @@ class LiveTrafficSummary {
}
}
final visibleEntries = filteredEntries.reversed.take(maxVisibleEntries).toList();
final visibleEntries = filteredEntries.reversed
.take(maxVisibleEntries)
.toList();
const txCount = 0;
final totalCount = rxCount;
final packetsPerMinute =
@@ -233,7 +343,9 @@ class LiveTrafficSummary {
avgRssiDbm: rssiCount == 0 ? null : rssiSum / rssiCount,
latestRssiDbm: latestRssiDbm,
multiHopCount: multiHopCount,
avgHopCount: hopCountSamples == 0 ? null : hopCountTotal / hopCountSamples,
avgHopCount: hopCountSamples == 0
? null
: hopCountTotal / hopCountSamples,
visibleEntries: visibleEntries,
busyness: _busynessForPacketsPerMinute(packetsPerMinute),
);

View File

@@ -20,6 +20,20 @@ import 'profiles_feature_service.dart';
/// - MeshCore mesh network integration
/// - Real-time position updates via callbacks
class LocationTrackingService {
static const double _defaultFastLocationMovementThresholdMeters = 10.0;
static const double _minFastLocationMovementThresholdMeters = 10.0;
static const int _defaultFastLocationActiveCadenceSeconds = 60;
static const int _minFastLocationActiveCadenceSeconds = 60;
static const int _maxFastLocationActiveCadenceSeconds = 60;
static const Duration _fastLocationSlowInterval = Duration(
seconds: 60,
);
static const Duration _fastLocationWalkingInterval = Duration(seconds: 30);
static const Duration _fastLocationFastInterval = Duration(seconds: 15);
static const Duration _fastLocationVeryFastInterval = Duration(seconds: 5);
static const double _fastLocationIdleSpeedMaxMetersPerSecond = 0.75;
static const double _fastLocationWalkingSpeedMaxMetersPerSecond = 1.8;
static const double _fastLocationFastSpeedMaxMetersPerSecond = 4.0;
// ============================================================================
// Singleton Pattern
// ============================================================================
@@ -49,6 +63,7 @@ class LocationTrackingService {
'fast_location_movement_threshold_meters';
static const String _prefKeyFastActiveCadence =
'fast_location_active_cadence_seconds';
static const String _prefKeyFastChannelIdx = 'fast_location_channel_idx';
String _scopedKey(String baseKey) {
return ProfileStorageScope.scopedKey(baseKey);
@@ -74,10 +89,15 @@ class LocationTrackingService {
bool fastLocationUpdatesEnabled = false;
/// Distance threshold for fast GPS updates
double fastLocationMovementThresholdMeters = 10.0;
double fastLocationMovementThresholdMeters =
_defaultFastLocationMovementThresholdMeters;
/// Cadence for active-use fast GPS updates
int fastLocationActiveCadenceSeconds = 10;
int fastLocationActiveCadenceSeconds =
_defaultFastLocationActiveCadenceSeconds;
/// Target channel index for fast GPS updates; null means disabled/unset.
int? fastLocationChannelIdx;
// ============================================================================
// State Properties
@@ -518,18 +538,30 @@ class LocationTrackingService {
}
Future<void> updateFastLocationMovementThreshold(double meters) async {
fastLocationMovementThresholdMeters = meters.clamp(1.0, 1000.0);
fastLocationMovementThresholdMeters = meters.clamp(
_minFastLocationMovementThresholdMeters,
1000.0,
);
await saveSettings();
}
Future<void> updateFastLocationActiveCadenceSeconds(int seconds) async {
fastLocationActiveCadenceSeconds = seconds.clamp(5, 60);
fastLocationActiveCadenceSeconds = seconds.clamp(
_minFastLocationActiveCadenceSeconds,
_maxFastLocationActiveCadenceSeconds,
);
await saveSettings();
_refreshFastLocationTimer();
}
Future<void> updateFastLocationChannelIdx(int? channelIdx) async {
fastLocationChannelIdx = channelIdx;
await saveSettings();
_refreshFastLocationTimer();
}
void _evaluateFastLocationMovement(Position position) {
if (!fastLocationUpdatesEnabled) return;
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
final previous = _lastFastLocationSentPosition;
if (previous == null) {
_emitFastLocationUpdate(position, reason: 'initial');
@@ -552,6 +584,7 @@ class LocationTrackingService {
_fastLocationTimer = null;
if (!isTracking ||
!fastLocationUpdatesEnabled ||
fastLocationChannelIdx == null ||
!_isFastLocationActiveUse) {
return;
}
@@ -567,7 +600,7 @@ class LocationTrackingService {
}
void _emitFastLocationUpdate(Position position, {required String reason}) {
if (!fastLocationUpdatesEnabled) return;
if (!fastLocationUpdatesEnabled || fastLocationChannelIdx == null) return;
final now = DateTime.now();
final previous = _lastFastLocationSentPosition;
@@ -580,9 +613,19 @@ class LocationTrackingService {
position.longitude,
);
final elapsedMs = now.difference(previousTime).inMilliseconds;
final minimumInterval = _fastLocationMinimumIntervalFor(
distanceMeters: distance,
elapsedMs: elapsedMs,
);
if (elapsedMs < minimumInterval.inMilliseconds) {
return;
}
if (distance < 1.0 && elapsedMs < 3000) {
return;
}
} else if (previousTime != null &&
now.difference(previousTime) < _fastLocationSlowInterval) {
return;
}
_lastFastLocationSentPosition = position;
@@ -590,6 +633,26 @@ class LocationTrackingService {
onFastLocationUpdate?.call(position, reason);
}
Duration _fastLocationMinimumIntervalFor({
required double distanceMeters,
required int elapsedMs,
}) {
if (elapsedMs <= 0) {
return _fastLocationSlowInterval;
}
final speedMetersPerSecond = distanceMeters / (elapsedMs / 1000.0);
if (speedMetersPerSecond < _fastLocationIdleSpeedMaxMetersPerSecond) {
return _fastLocationSlowInterval;
}
if (speedMetersPerSecond < _fastLocationWalkingSpeedMaxMetersPerSecond) {
return _fastLocationWalkingInterval;
}
if (speedMetersPerSecond < _fastLocationFastSpeedMaxMetersPerSecond) {
return _fastLocationFastInterval;
}
return _fastLocationVeryFastInterval;
}
// ============================================================================
// Mesh Network Broadcasting
// ============================================================================
@@ -662,13 +725,16 @@ class LocationTrackingService {
prefs.getBool(_scopedKey(_prefKeyFastLocationEnabled)) ?? false;
fastLocationMovementThresholdMeters =
(prefs.getDouble(_scopedKey(_prefKeyFastMovementThreshold)) ??
gpsUpdateDistance)
.clamp(1.0, 1000.0);
_defaultFastLocationMovementThresholdMeters)
.clamp(_minFastLocationMovementThresholdMeters, 1000.0);
fastLocationActiveCadenceSeconds =
(prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ?? 10).clamp(
5,
60,
(prefs.getInt(_scopedKey(_prefKeyFastActiveCadence)) ??
_defaultFastLocationActiveCadenceSeconds)
.clamp(
_minFastLocationActiveCadenceSeconds,
_maxFastLocationActiveCadenceSeconds,
);
fastLocationChannelIdx = prefs.getInt(_scopedKey(_prefKeyFastChannelIdx));
debugPrint('✅ [LocationTracking] Settings loaded');
debugPrint(' Min distance: ${minDistanceMeters}m');
@@ -680,6 +746,7 @@ class LocationTrackingService {
' Fast movement threshold: ${fastLocationMovementThresholdMeters}m',
);
debugPrint(' Fast active cadence: ${fastLocationActiveCadenceSeconds}s');
debugPrint(' Fast channel idx: ${fastLocationChannelIdx ?? "unset"}');
}
/// Save settings to SharedPreferences
@@ -709,6 +776,12 @@ class LocationTrackingService {
_scopedKey(_prefKeyFastActiveCadence),
fastLocationActiveCadenceSeconds,
);
final channelKey = _scopedKey(_prefKeyFastChannelIdx);
if (fastLocationChannelIdx == null) {
await prefs.remove(channelKey);
} else {
await prefs.setInt(channelKey, fastLocationChannelIdx!);
}
debugPrint('✅ [LocationTracking] Settings saved');
}

View File

@@ -64,7 +64,6 @@ class MapWorkspaceSnapshotService {
showPlaceNamesOverlay: mapProvider.showPlaceNamesOverlay,
showMunicipalityBordersOverlay:
mapProvider.showMunicipalityBordersOverlay,
showAllContactTrails: mapProvider.showAllContactTrails,
hideRepeatersOnMap: mapProvider.hideRepeatersOnMap,
);
}

View File

@@ -45,8 +45,7 @@ class MeshMapNodesService {
'https://api.meshcore.nz/api/v1/map/nodes';
static const int repeaterType = 1;
static const Duration _cacheTtl = Duration(hours: 24);
static const Duration traceCacheTtl = _cacheTtl;
static const Duration traceTimeout = Duration(seconds: 30);
static const Duration _requestTimeout = Duration(seconds: 30);
static const String _cacheKey = 'mesh_map_nodes_cache_v1';
static const String _cacheTimestampKey = 'mesh_map_nodes_cache_timestamp_v1';
static List<MeshMapNode>? _cachedNodes;
@@ -74,7 +73,7 @@ class MeshMapNodesService {
final response = await (client ?? http.Client())
.get(Uri.parse(_nodesEndpoint))
.timeout(traceTimeout);
.timeout(_requestTimeout);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Map nodes API returned ${response.statusCode}');
}

View File

@@ -5,6 +5,12 @@ import 'package:shared_preferences/shared_preferences.dart';
class MessageDestinationPreferences {
static const String _destinationTypeKey = 'message_destination_type';
static const String _recipientPublicKeyKey = 'message_recipient_public_key';
static const String _lockedDestinationEnabledKey =
'message_locked_destination_enabled';
static const String _lockedDestinationTypeKey =
'message_locked_destination_type';
static const String _lockedRecipientPublicKeyKey =
'message_locked_recipient_public_key';
/// Destination types
static const String destinationTypeAll = 'all';
@@ -12,6 +18,10 @@ class MessageDestinationPreferences {
static const String destinationTypeContact = 'contact';
static const String destinationTypeRoom = 'room';
static bool isLockableDestinationType(String type) {
return type == destinationTypeChannel || type == destinationTypeRoom;
}
/// Get the saved destination configuration
/// Returns a map with 'type' and optional 'publicKey'
/// Returns null if no preference is saved (defaults to public channel)
@@ -53,6 +63,53 @@ class MessageDestinationPreferences {
await prefs.remove(_recipientPublicKeyKey);
}
/// Get the saved locked destination configuration.
/// Returns null when the lock is disabled.
static Future<Map<String, String>?> getLockedDestination() async {
final prefs = await SharedPreferences.getInstance();
final isEnabled = prefs.getBool(_lockedDestinationEnabledKey) ?? false;
if (!isEnabled) {
return null;
}
final savedType =
prefs.getString(_lockedDestinationTypeKey) ?? destinationTypeChannel;
final type = isLockableDestinationType(savedType)
? savedType
: destinationTypeChannel;
final publicKey = prefs.getString(_lockedRecipientPublicKeyKey);
return {'type': type, 'publicKey': ?publicKey};
}
static Future<void> setLockedDestination({
required bool enabled,
String type = destinationTypeChannel,
String? recipientPublicKey,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_lockedDestinationEnabledKey, enabled);
if (!enabled) {
await prefs.remove(_lockedDestinationTypeKey);
await prefs.remove(_lockedRecipientPublicKeyKey);
return;
}
final sanitizedType = isLockableDestinationType(type)
? type
: destinationTypeChannel;
await prefs.setString(_lockedDestinationTypeKey, sanitizedType);
if (recipientPublicKey != null) {
await prefs.setString(_lockedRecipientPublicKeyKey, recipientPublicKey);
} else {
await prefs.remove(_lockedRecipientPublicKeyKey);
}
}
/// Get display name for destination type
static String getDestinationTypeName(String type) {
switch (type) {

View File

@@ -58,10 +58,7 @@ class MessageStorageService {
: jsonList;
final jsonString = jsonEncode(limitedList);
await prefs.setString(
_key(_messagesKey, namespace: namespace),
jsonString,
);
final messagesKey = _key(_messagesKey, namespace: namespace);
final retainedMessageIds = limitedList
.map((entry) => entry['id'] as String)
.toSet();
@@ -89,22 +86,41 @@ class MessageStorageService {
routeMetadataJson[entry.key] = entry.value.toJson();
}
}
await prefs.setString(
_key(_messageContactLocationsKey, namespace: namespace),
jsonEncode(locationJson),
final contactLocationsKey = _key(
_messageContactLocationsKey,
namespace: namespace,
);
await prefs.setString(
_key(_messageReceptionDetailsKey, namespace: namespace),
jsonEncode(receptionJson),
final receptionDetailsKey = _key(
_messageReceptionDetailsKey,
namespace: namespace,
);
await prefs.setString(
_key(_messageTransferDetailsKey, namespace: namespace),
jsonEncode(transferJson),
final transferDetailsKey = _key(
_messageTransferDetailsKey,
namespace: namespace,
);
await prefs.setString(
_key(_messageRouteMetadataKey, namespace: namespace),
jsonEncode(routeMetadataJson),
final routeMetadataKey = _key(
_messageRouteMetadataKey,
namespace: namespace,
);
final locationJsonString = jsonEncode(locationJson);
final receptionJsonString = jsonEncode(receptionJson);
final transferJsonString = jsonEncode(transferJson);
final routeMetadataJsonString = jsonEncode(routeMetadataJson);
final hasChanges =
prefs.getString(messagesKey) != jsonString ||
prefs.getString(contactLocationsKey) != locationJsonString ||
prefs.getString(receptionDetailsKey) != receptionJsonString ||
prefs.getString(transferDetailsKey) != transferJsonString ||
prefs.getString(routeMetadataKey) != routeMetadataJsonString;
if (!hasChanges) {
return;
}
await prefs.setString(messagesKey, jsonString);
await prefs.setString(contactLocationsKey, locationJsonString);
await prefs.setString(receptionDetailsKey, receptionJsonString);
await prefs.setString(transferDetailsKey, transferJsonString);
await prefs.setString(routeMetadataKey, routeMetadataJsonString);
debugPrint(
'✅ [MessageStorage] Saved ${limitedList.length} messages to storage',

View File

@@ -2,30 +2,20 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
class MessagingRoutePreferences {
static const bool defaultAutoRouteRotationEnabled = false;
static const bool defaultClearPathOnMaxRetry = false;
static const bool defaultNearestRelayFallbackEnabled = true;
static const String _autoRouteRotationKey =
static const String _legacyAutoRouteRotationKey =
'messaging_auto_route_rotation_enabled';
static const String _clearPathOnMaxRetryKey =
'messaging_clear_path_on_max_retry';
static const String _nearestRelayFallbackKey =
'messaging_nearest_relay_fallback_enabled';
static Future<bool> getAutoRouteRotationEnabled() async {
static Future<void> cleanupLegacySettings() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
) ??
defaultAutoRouteRotationEnabled;
}
static Future<void> setAutoRouteRotationEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(
ProfileStorageScope.scopedKey(_autoRouteRotationKey),
enabled,
await prefs.remove(
ProfileStorageScope.scopedKey(_legacyAutoRouteRotationKey),
);
}

View File

@@ -5,18 +5,23 @@ import 'package:nsd/nsd.dart';
/// Discovered MeshCore device on the network (TCP/WiFi)
class DiscoveredServer {
final String name;
final String ipAddress;
final int port;
final int responseTime; // milliseconds
const DiscoveredServer({
required this.name,
required this.ipAddress,
required this.port,
required this.responseTime,
});
String get displayName => name.trim().isNotEmpty ? name.trim() : ipAddress;
@override
String toString() => 'DiscoveredServer($ipAddress:$port, ${responseTime}ms)';
String toString() =>
'DiscoveredServer($displayName @ $ipAddress:$port, ${responseTime}ms)';
@override
bool operator ==(Object other) =>
@@ -46,6 +51,7 @@ class NetworkScannerService {
bool _isScanning = false;
bool get isScanning => _isScanning;
int _scanSession = 0;
List<DiscoveredServer> _cachedServers = [];
List<DiscoveredServer> get cachedServers => List.unmodifiable(_cachedServers);
@@ -88,7 +94,11 @@ class NetworkScannerService {
}
/// Try a raw TCP connect to check if the MeshCore TCP server is listening.
Future<DiscoveredServer?> _checkDevice(String ip, int port) async {
Future<DiscoveredServer?> _checkDevice(
String ip,
int port, {
String? name,
}) async {
final sw = Stopwatch()..start();
Socket? socket;
try {
@@ -101,6 +111,7 @@ class NetworkScannerService {
debugPrint(
'✅ [NetworkScanner] Found device at $ip:$port (${sw.elapsedMilliseconds}ms)');
return DiscoveredServer(
name: (name ?? ip).trim(),
ipAddress: ip,
port: port,
responseTime: sw.elapsedMilliseconds,
@@ -117,7 +128,10 @@ class NetworkScannerService {
// ── mDNS discovery ─────────────────────────────────────────────────────────
Future<List<DiscoveredServer>> _discoverViaMdns({int? port}) async {
Future<List<DiscoveredServer>> _discoverViaMdns({
int? port,
required int scanSession,
}) async {
final scanPort = port ?? defaultPort;
final found = <DiscoveredServer>[];
@@ -131,12 +145,25 @@ class NetworkScannerService {
);
await Future.delayed(bonjourTimeout);
if (!_isScanSessionActive(scanSession)) {
return found;
}
for (final service in _activeDiscovery?.services ?? []) {
if (!_isScanSessionActive(scanSession)) {
break;
}
for (final addr in service.addresses ?? []) {
if (!_isScanSessionActive(scanSession)) {
break;
}
if (localIps.contains(addr.address)) continue;
final result =
await _checkDevice(addr.address, service.port ?? scanPort);
await _checkDevice(
addr.address,
service.port ?? scanPort,
name: service.name,
);
if (result != null) {
found.add(result);
onServerDiscovered?.call(result);
@@ -163,7 +190,10 @@ class NetworkScannerService {
// ── Port scan fallback ─────────────────────────────────────────────────────
Future<List<DiscoveredServer>> _scanByPort({int? port}) async {
Future<List<DiscoveredServer>> _scanByPort({
int? port,
required int scanSession,
}) async {
final scanPort = port ?? defaultPort;
final found = <DiscoveredServer>[];
@@ -175,10 +205,12 @@ class NetworkScannerService {
'🔍 [NetworkScanner] Port scan: ${ips.length} IPs, port $scanPort');
int scanned = 0;
for (int i = 0; i < ips.length; i += parallelScans) {
for (int i = 0; i < ips.length && _isScanSessionActive(scanSession); i += parallelScans) {
final batch = ips.skip(i).take(parallelScans).toList();
final results =
await Future.wait(batch.map((ip) => _checkDevice(ip, scanPort)));
await Future.wait(
batch.map((ip) => _checkDevice(ip, scanPort, name: ip)),
);
for (final result in results) {
if (result != null && !localIps.contains(result.ipAddress)) {
@@ -200,28 +232,51 @@ class NetworkScannerService {
Future<List<DiscoveredServer>> scan({int? port}) async {
if (_isScanning) return [];
_isScanning = true;
final scanSession = ++_scanSession;
try {
var found = await _discoverViaMdns(port: port);
if (found.isEmpty) {
var found = await _discoverViaMdns(port: port, scanSession: scanSession);
if (_isScanSessionActive(scanSession) && found.isEmpty) {
debugPrint(
'🔍 [NetworkScanner] mDNS found nothing, falling back to port scan');
found = await _scanByPort(port: port);
found = await _scanByPort(port: port, scanSession: scanSession);
}
if (_isScanSessionActive(scanSession)) {
_cachedServers = found;
}
_cachedServers = found;
return found;
} finally {
_isScanning = false;
if (_scanSession == scanSession) {
_isScanning = false;
}
}
}
/// Verify a previously discovered device is still reachable.
Future<bool> verifyServer(DiscoveredServer server) async {
final result = await _checkDevice(server.ipAddress, server.port);
final result = await _checkDevice(
server.ipAddress,
server.port,
name: server.name,
);
return result != null;
}
void clearCache() => _cachedServers = [];
void stopScan() => _isScanning = false;
bool _isScanSessionActive(int session) => _isScanning && _scanSession == session;
void stopScan() {
_scanSession += 1;
_isScanning = false;
final activeDiscovery = _activeDiscovery;
_activeDiscovery = null;
if (activeDiscovery != null) {
unawaited(
stopDiscovery(activeDiscovery).catchError((Object error) {
debugPrint('⚠️ [NetworkScanner] Failed to stop mDNS discovery: $error');
}),
);
}
}
}

View File

@@ -737,7 +737,7 @@ class NotificationService {
presentSound: false,
threadIdentifier: 'app_updates',
categoryIdentifier: 'APP_UPDATE',
subtitle: 'New version: $latestVersion',
subtitle: localizations?.newVersionAvailable(latestVersion) ?? 'New version: $latestVersion',
);
// Combined notification details

View File

@@ -0,0 +1,180 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'offline_tile_cache_service.dart';
/// A [MapCachingProvider] that checks the offline AVIF tile cache before
/// falling through to the built-in cache/network path.
///
/// This allows preloaded tiles to be served during normal map browsing.
class OfflineMapCachingProvider implements MapCachingProvider {
final MapCachingProvider _delegate;
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
OfflineMapCachingProvider(this._delegate);
@override
bool get isSupported => true;
@override
Future<CachedMapTile?> getTile(String url) async {
// Extract tile coordinates from URL to check our AVIF cache
final coords = _parseTileUrl(url);
if (coords != null) {
final styleHash = _cache.styleHashFromUrl(_extractUrlTemplate(url));
// Check local AVIF cache first
final pngBytes = await _cache.getTileAsPng(
styleHash, coords.z, coords.x, coords.y);
if (pngBytes != null) {
return (
bytes: pngBytes,
metadata: CachedMapTileMetadata(
staleAt: DateTime.now().add(const Duration(days: 365)),
lastModified: null,
etag: null,
),
);
}
}
// Fall through to delegate (built-in cache)
return _delegate.getTile(url);
}
@override
Future<void> putTile({
required String url,
required CachedMapTileMetadata metadata,
Uint8List? bytes,
}) {
// Only delegate to built-in cache for normal browsing tiles
return _delegate.putTile(url: url, metadata: metadata, bytes: bytes);
}
@visibleForTesting
static ({int z, int x, int y})? parseTileUrlForTesting(String url) {
final coords = _parseTileUrl(url);
if (coords == null) {
return null;
}
return (z: coords.z, x: coords.x, y: coords.y);
}
@visibleForTesting
static String extractUrlTemplateForTesting(String url) {
return _extractUrlTemplate(url);
}
/// Parse z/x/y from a tile URL.
static _TileCoords? _parseTileUrl(String url) {
final queryStyleMatch = RegExp(
r'(?:\?|&|/)(?:[^#]*&)?x=(\d+)&y=(\d+)&z=(\d+)(?:&|$)',
).firstMatch(url);
if (queryStyleMatch != null) {
return _TileCoords(
z: int.parse(queryStyleMatch.group(3)!),
x: int.parse(queryStyleMatch.group(1)!),
y: int.parse(queryStyleMatch.group(2)!),
);
}
final uri = Uri.tryParse(url);
if (uri != null) {
final z = int.tryParse(uri.queryParameters['z'] ?? '');
final x = int.tryParse(uri.queryParameters['x'] ?? '');
final y = int.tryParse(uri.queryParameters['y'] ?? '');
if (z != null && x != null && y != null) {
return _TileCoords(z: z, x: x, y: y);
}
}
// Match known path formats.
final yxTilePattern = RegExp(r'/tile/(\d+)/(\d+)/(\d+)$');
final yxTileMatch = yxTilePattern.firstMatch(url);
if (yxTileMatch != null) {
return _TileCoords(
z: int.parse(yxTileMatch.group(1)!),
x: int.parse(yxTileMatch.group(3)!),
y: int.parse(yxTileMatch.group(2)!),
);
}
final patterns = [
RegExp(r'/(\d+)/(\d+)/(\d+)\.(?:png|jpg|jpeg|webp)'),
RegExp(r'/(\d+)/(\d+)/(\d+)$'),
];
for (final pattern in patterns) {
final match = pattern.firstMatch(url);
if (match != null) {
return _TileCoords(
z: int.parse(match.group(1)!),
x: int.parse(match.group(2)!),
y: int.parse(match.group(3)!),
);
}
}
return null;
}
/// Extract a URL template from a concrete URL by replacing coordinates.
static String _extractUrlTemplate(String url) {
final queryStylePattern = RegExp(r'([?&])x=\d+&y=\d+&z=\d+');
if (queryStylePattern.hasMatch(url)) {
return url.replaceAllMapped(
queryStylePattern,
(match) => '${match.group(1)}x={x}&y={y}&z={z}',
);
}
final pathStyleQueryPattern = RegExp(r'(&)x=\d+&y=\d+&z=\d+');
if (pathStyleQueryPattern.hasMatch(url)) {
return url.replaceAllMapped(
pathStyleQueryPattern,
(match) => '${match.group(1)}x={x}&y={y}&z={z}',
);
}
final uri = Uri.tryParse(url);
if (uri != null) {
final query = Map<String, String>.from(uri.queryParameters);
var replacedQuery = false;
if (query.containsKey('z')) {
query['z'] = '{z}';
replacedQuery = true;
}
if (query.containsKey('x')) {
query['x'] = '{x}';
replacedQuery = true;
}
if (query.containsKey('y')) {
query['y'] = '{y}';
replacedQuery = true;
}
if (replacedQuery) {
return uri.replace(queryParameters: query).toString();
}
}
final yxTilePattern = RegExp(r'/tile/(\d+)/(\d+)/(\d+)$');
final yxTileMatch = yxTilePattern.firstMatch(url);
if (yxTileMatch != null) {
return url.replaceRange(
yxTileMatch.start,
yxTileMatch.end,
'/tile/{z}/{y}/{x}',
);
}
return url.replaceAllMapped(
RegExp(r'/(\d+)/(\d+)/(\d+)(\.(?:png|jpg|jpeg|webp))?$'),
(m) => '/{z}/{x}/{y}${m.group(4) ?? ''}',
);
}
}
class _TileCoords {
final int z, x, y;
const _TileCoords({required this.z, required this.x, required this.y});
}

View File

@@ -0,0 +1,481 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_avif/flutter_avif.dart';
import 'package:path_provider/path_provider.dart';
/// A saved download region (polygons + zoom range) for quick re-download.
class DownloadRegion {
final List<List<List<double>>> polygons; // [polygon][vertex][lat, lng]
final int minZoom;
final int maxZoom;
const DownloadRegion({
required this.polygons,
required this.minZoom,
required this.maxZoom,
});
Map<String, dynamic> toJson() => {
'polygons': polygons,
'minZoom': minZoom,
'maxZoom': maxZoom,
};
factory DownloadRegion.fromJson(Map<String, dynamic> json) {
final rawPolygons = json['polygons'] as List<dynamic>? ?? [];
final polygons = rawPolygons.map<List<List<double>>>((poly) {
return (poly as List<dynamic>).map<List<double>>((vertex) {
final v = vertex as List<dynamic>;
return [
(v[0] as num).toDouble(),
(v[1] as num).toDouble(),
];
}).toList();
}).toList();
return DownloadRegion(
polygons: polygons,
minZoom: json['minZoom'] as int? ?? 8,
maxZoom: json['maxZoom'] as int? ?? 14,
);
}
}
/// Metadata about a cached map style.
class StyleInfo {
final String hash;
final String displayName;
final String urlTemplate;
final int tileCount;
final int sizeBytes;
final DownloadRegion? region;
const StyleInfo({
required this.hash,
required this.displayName,
required this.urlTemplate,
this.tileCount = 0,
this.sizeBytes = 0,
this.region,
});
Map<String, dynamic> toJson() => {
'hash': hash,
'displayName': displayName,
'urlTemplate': urlTemplate,
'tileCount': tileCount,
'sizeBytes': sizeBytes,
if (region != null) 'region': region!.toJson(),
};
factory StyleInfo.fromJson(Map<String, dynamic> json) => StyleInfo(
hash: json['hash'] as String,
displayName: json['displayName'] as String? ?? json['hash'] as String,
urlTemplate: json['urlTemplate'] as String? ?? '',
tileCount: json['tileCount'] as int? ?? 0,
sizeBytes: json['sizeBytes'] as int? ?? 0,
region: json['region'] != null
? DownloadRegion.fromJson(json['region'] as Map<String, dynamic>)
: null,
);
}
/// A tile coordinate in the cache (z/x/y).
class CachedTileCoord {
final int z, x, y;
const CachedTileCoord(this.z, this.x, this.y);
Map<String, int> toJson() => {'z': z, 'x': x, 'y': y};
factory CachedTileCoord.fromJson(Map<String, dynamic> json) =>
CachedTileCoord(
json['z'] as int,
json['x'] as int,
json['y'] as int,
);
}
/// Manages the offline AVIF tile cache on disk.
///
/// Tiles are stored as `{baseDir}/offline_tiles/{styleHash}/{z}/{x}/{y}.avif`.
/// Style metadata is stored as `{baseDir}/offline_tiles/{styleHash}/meta.json`.
/// This cache is separate from flutter_map's built-in cache and is used for
/// proactively downloaded tiles and WiFi sharing.
class OfflineTileCacheService {
OfflineTileCacheService._();
static final instance = OfflineTileCacheService._();
String? _baseDir;
Future<String> get baseDir async {
if (_baseDir != null) return _baseDir!;
final docs = await getApplicationDocumentsDirectory();
_baseDir = '${docs.path}/offline_tiles';
return _baseDir!;
}
/// Derive a short deterministic hash from a URL template.
String styleHashFromUrl(String urlTemplate) {
final bytes = sha256.convert(urlTemplate.codeUnits).bytes;
return bytes
.take(6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
}
String _tilePath(String base, String styleHash, int z, int x, int y) {
return '$base/$styleHash/$z/$x/$y.avif';
}
String _tileDir(String base, String styleHash, int z, int x) {
return '$base/$styleHash/$z/$x';
}
// In-memory manifest cache: styleHash → set of "z/x/y" keys.
// Loaded lazily, kept in sync with writes.
final Map<String, Set<String>> _manifests = {};
static String _tileKey(int z, int x, int y) => '$z/$x/$y';
String _manifestPath(String base, String styleHash) =>
'$base/$styleHash/manifest.txt';
/// Load the manifest for a style into memory (if not already loaded).
Future<Set<String>> loadManifest(String styleHash) async {
if (_manifests.containsKey(styleHash)) return _manifests[styleHash]!;
final base = await baseDir;
final file = File(_manifestPath(base, styleHash));
final Set<String> manifest;
if (await file.exists()) {
final lines = await file.readAsLines();
manifest = lines.where((l) => l.isNotEmpty).toSet();
} else {
// First time — scan the filesystem and build the manifest
manifest = {};
final styleDir = Directory('$base/$styleHash');
if (await styleDir.exists()) {
final avifPattern = RegExp(r'/(\d+)/(\d+)/(\d+)\.avif$');
await for (final entity in styleDir.list(recursive: true)) {
if (entity is! File) continue;
final match = avifPattern.firstMatch(entity.path);
if (match != null) {
manifest.add('${match.group(1)}/${match.group(2)}/${match.group(3)}');
}
}
// Persist the scanned manifest
await _writeManifest(base, styleHash, manifest);
}
}
_manifests[styleHash] = manifest;
return manifest;
}
Future<void> _writeManifest(
String base, String styleHash, Set<String> manifest) async {
final dir = Directory('$base/$styleHash');
if (!await dir.exists()) await dir.create(recursive: true);
await File(_manifestPath(base, styleHash))
.writeAsString(manifest.join('\n'), flush: true);
}
/// Append a tile key to the manifest (both in-memory and on disk).
Future<void> _addToManifest(
String base, String styleHash, String key) async {
_manifests[styleHash] ??= {};
if (_manifests[styleHash]!.add(key)) {
final file = File(_manifestPath(base, styleHash));
await file.writeAsString('$key\n',
mode: FileMode.append, flush: true);
}
}
/// Check if a tile exists in the cache (uses in-memory manifest).
Future<bool> hasTile(String styleHash, int z, int x, int y) async {
final manifest = await loadManifest(styleHash);
return manifest.contains(_tileKey(z, x, y));
}
/// Read a cached tile's raw AVIF bytes (for serving to peers).
Future<Uint8List?> getRawTile(String styleHash, int z, int x, int y) async {
final base = await baseDir;
final file = File(_tilePath(base, styleHash, z, x, y));
if (!await file.exists()) return null;
return file.readAsBytes();
}
/// Read a cached tile and decode AVIF → PNG bytes for flutter_map display.
Future<Uint8List?> getTileAsPng(
String styleHash, int z, int x, int y) async {
final avifBytes = await getRawTile(styleHash, z, x, y);
if (avifBytes == null) return null;
return _avifToPng(avifBytes);
}
/// Store a tile: encode PNG bytes → AVIF, write to disk, update manifest.
Future<void> putTile(
String styleHash,
int z,
int x,
int y,
Uint8List pngBytes,
) async {
final base = await baseDir;
final dir = Directory(_tileDir(base, styleHash, z, x));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
final avifBytes = await _pngToAvif(pngBytes);
if (avifBytes == null) {
await File(_tilePath(base, styleHash, z, x, y))
.writeAsBytes(pngBytes, flush: true);
} else {
await File(_tilePath(base, styleHash, z, x, y))
.writeAsBytes(avifBytes, flush: true);
}
await _addToManifest(base, styleHash, _tileKey(z, x, y));
}
/// Store raw AVIF bytes directly (from a peer), update manifest.
Future<void> putRawTile(
String styleHash,
int z,
int x,
int y,
Uint8List avifBytes,
) async {
final base = await baseDir;
final dir = Directory(_tileDir(base, styleHash, z, x));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
await File(_tilePath(base, styleHash, z, x, y))
.writeAsBytes(avifBytes, flush: true);
await _addToManifest(base, styleHash, _tileKey(z, x, y));
}
/// Get total cache size in bytes.
Future<int> getCacheSize() async {
final base = await baseDir;
final dir = Directory(base);
if (!await dir.exists()) return 0;
var totalSize = 0;
await for (final entity in dir.list(recursive: true)) {
if (entity is File) {
totalSize += await entity.length();
}
}
return totalSize;
}
/// List all style hashes that have cached tiles.
Future<List<String>> listStyles() async {
final base = await baseDir;
final dir = Directory(base);
if (!await dir.exists()) return [];
final styles = <String>[];
await for (final entity in dir.list()) {
if (entity is Directory) {
styles.add(entity.path.split('/').last);
}
}
return styles;
}
/// Save metadata for a style (name, URL template, download region).
Future<void> saveStyleMeta(
String styleHash, {
required String displayName,
required String urlTemplate,
DownloadRegion? region,
}) async {
final base = await baseDir;
final dir = Directory('$base/$styleHash');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
// Merge with existing meta to preserve region if not provided
final metaFile = File('$base/$styleHash/meta.json');
Map<String, dynamic> meta = {
'displayName': displayName,
'urlTemplate': urlTemplate,
};
if (region != null) {
meta['region'] = region.toJson();
} else if (await metaFile.exists()) {
try {
final existing = jsonDecode(await metaFile.readAsString());
if (existing['region'] != null) {
meta['region'] = existing['region'];
}
} catch (_) {}
}
await metaFile.writeAsString(jsonEncode(meta), flush: true);
}
/// Read metadata for a style.
Future<StyleInfo?> getStyleMeta(String styleHash) async {
final base = await baseDir;
final metaFile = File('$base/$styleHash/meta.json');
if (!await metaFile.exists()) return null;
try {
final json = jsonDecode(await metaFile.readAsString());
return StyleInfo(
hash: styleHash,
displayName: json['displayName'] as String? ?? styleHash,
urlTemplate: json['urlTemplate'] as String? ?? '',
region: json['region'] != null
? DownloadRegion.fromJson(json['region'] as Map<String, dynamic>)
: null,
);
} catch (_) {
return null;
}
}
/// List all styles with metadata, tile counts, and sizes.
Future<List<StyleInfo>> listStylesDetailed() async {
final base = await baseDir;
final dir = Directory(base);
if (!await dir.exists()) return [];
final results = <StyleInfo>[];
await for (final entity in dir.list()) {
if (entity is! Directory) continue;
final hash = entity.path.split('/').last;
// Read meta
String displayName = hash;
String urlTemplate = '';
DownloadRegion? region;
final metaFile = File('${entity.path}/meta.json');
if (await metaFile.exists()) {
try {
final json = jsonDecode(await metaFile.readAsString());
displayName = json['displayName'] as String? ?? hash;
urlTemplate = json['urlTemplate'] as String? ?? '';
if (json['region'] != null) {
region = DownloadRegion.fromJson(
json['region'] as Map<String, dynamic>);
}
} catch (_) {}
}
// Count tiles and size
var tileCount = 0;
var sizeBytes = 0;
await for (final file in entity.list(recursive: true)) {
if (file is File && file.path.endsWith('.avif')) {
tileCount++;
sizeBytes += await file.length();
}
}
results.add(StyleInfo(
hash: hash,
displayName: displayName,
urlTemplate: urlTemplate,
tileCount: tileCount,
sizeBytes: sizeBytes,
region: region,
));
}
return results;
}
/// List all tile coordinates cached for a given style.
Future<List<CachedTileCoord>> listTilesForStyle(String styleHash) async {
final base = await baseDir;
final styleDir = Directory('$base/$styleHash');
if (!await styleDir.exists()) return [];
final tiles = <CachedTileCoord>[];
final avifPattern = RegExp(r'/(\d+)/(\d+)/(\d+)\.avif$');
await for (final entity in styleDir.list(recursive: true)) {
if (entity is! File) continue;
final match = avifPattern.firstMatch(entity.path);
if (match != null) {
tiles.add(CachedTileCoord(
int.parse(match.group(1)!),
int.parse(match.group(2)!),
int.parse(match.group(3)!),
));
}
}
return tiles;
}
/// Delete a single style's tiles, manifest, and metadata.
Future<void> deleteStyle(String styleHash) async {
_manifests.remove(styleHash);
final base = await baseDir;
final dir = Directory('$base/$styleHash');
if (await dir.exists()) {
await dir.delete(recursive: true);
}
}
/// Delete all cached tiles, manifests, and metadata.
Future<void> clearCache() async {
_manifests.clear();
final base = await baseDir;
final dir = Directory(base);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
}
/// Decode AVIF bytes to PNG (static helper for use by caching provider).
static Future<Uint8List?> getTileAsPngStatic(Uint8List avifBytes) {
return _avifToPng(avifBytes);
}
/// Encode PNG → AVIF for tile storage.
/// Uses moderate quality for good compression with acceptable quality.
static Future<Uint8List?> _pngToAvif(Uint8List pngBytes) async {
try {
final avif = await encodeAvif(
pngBytes,
maxThreads: 2,
maxQuantizer: 40, // Good quality (0=lossless, 63=worst)
minQuantizer: 25,
maxQuantizerAlpha: 63,
minQuantizerAlpha: 63,
speed: 6,
keepExif: false,
);
if (avif.isEmpty) return null;
return avif;
} catch (e) {
debugPrint('[OfflineTileCache] AVIF encode error: $e');
return null;
}
}
/// Decode AVIF → PNG bytes for display.
static Future<Uint8List?> _avifToPng(Uint8List avifBytes) async {
try {
// Use Flutter's image codec which can handle AVIF via flutter_avif
final codec = await ui.instantiateImageCodec(avifBytes);
final frame = await codec.getNextFrame();
final image = frame.image;
final byteData =
await image.toByteData(format: ui.ImageByteFormat.png);
image.dispose();
return byteData?.buffer.asUint8List();
} catch (e) {
debugPrint('[OfflineTileCache] AVIF decode error: $e');
return null;
}
}
}

View File

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

View File

@@ -14,6 +14,8 @@ import '../providers/drawing_provider.dart';
import '../providers/map_provider.dart';
import '../providers/messages_provider.dart';
import '../providers/sensors_provider.dart';
import '../providers/voice_provider.dart';
import '../providers/image_provider.dart' as ip;
import 'app_config_snapshot_service.dart';
import 'contact_storage_service.dart';
import 'device_config_applicator.dart';
@@ -33,6 +35,8 @@ class ProfileWorkspaceCoordinator {
required this.mapProvider,
required this.drawingProvider,
required this.channelsProvider,
required this.voiceProvider,
required this.imageProvider,
required this.appProvider,
AppConfigSnapshotService? appConfigSnapshotService,
MapWorkspaceSnapshotService? mapWorkspaceSnapshotService,
@@ -58,13 +62,15 @@ class ProfileWorkspaceCoordinator {
final MapProvider mapProvider;
final DrawingProvider drawingProvider;
final ChannelsProvider channelsProvider;
final VoiceProvider voiceProvider;
final ip.ImageProvider imageProvider;
final AppProvider appProvider;
final AppConfigSnapshotService _appConfigSnapshotService;
final MapWorkspaceSnapshotService _mapWorkspaceSnapshotService;
final DeviceConfigApplicator _deviceConfigApplicator;
final MessageStorageService _messageStorageService;
final ContactStorageService _contactStorageService;
bool _isSyncingDeviceProfile = false;
Future<void>? _deviceProfileSyncFuture;
Future<void> setProfilesEnabled(bool enabled) async {
final wasEnabled = profileManager.profilesEnabled;
@@ -279,41 +285,54 @@ class ProfileWorkspaceCoordinator {
}
Future<void> syncActiveProfileForCurrentDevice() async {
if (!profileManager.profilesEnabled || _isSyncingDeviceProfile) {
if (!profileManager.profilesEnabled) {
return;
}
final inFlightSync = _deviceProfileSyncFuture;
if (inFlightSync != null) {
await inFlightSync;
return;
}
final syncFuture = _syncActiveProfileForCurrentDeviceInternal();
_deviceProfileSyncFuture = syncFuture;
try {
await syncFuture;
} finally {
if (identical(_deviceProfileSyncFuture, syncFuture)) {
_deviceProfileSyncFuture = null;
}
}
}
Future<void> _syncActiveProfileForCurrentDeviceInternal() async {
final deviceKey = _currentDeviceProfileKey;
if (deviceKey == null) {
return;
}
_isSyncingDeviceProfile = true;
try {
final profile = await _ensureProfileForCurrentDevice();
final targetProfileId = profile.id;
if (targetProfileId == profileManager.activeProfileId) {
return;
}
await _persistCurrentState();
await profileManager.setActiveProfileIdForDevice(
targetProfileId,
deviceKey: deviceKey,
);
await _switchRuntimeScope(targetProfileId);
await _appConfigSnapshotService.apply(
profile.sections.appSettings,
appProvider,
);
await _mapWorkspaceSnapshotService.apply(
profile.sections.mapWorkspace,
mapProvider: mapProvider,
drawingProvider: drawingProvider,
);
} finally {
_isSyncingDeviceProfile = false;
final profile = await _ensureProfileForCurrentDevice();
final targetProfileId = profile.id;
if (targetProfileId == profileManager.activeProfileId) {
return;
}
await _persistCurrentState();
await profileManager.setActiveProfileIdForDevice(
targetProfileId,
deviceKey: deviceKey,
);
await _switchRuntimeScope(targetProfileId);
await _appConfigSnapshotService.apply(
profile.sections.appSettings,
appProvider,
);
await _mapWorkspaceSnapshotService.apply(
profile.sections.mapWorkspace,
mapProvider: mapProvider,
drawingProvider: drawingProvider,
);
}
Future<ConfigProfile> _ensureProfileForCurrentDevice() async {
@@ -387,6 +406,8 @@ class ProfileWorkspaceCoordinator {
await sensorsProvider.reloadProfileScopedState();
await drawingProvider.reloadProfileScopedState();
await mapProvider.reloadProfileScopedState();
await voiceProvider.reloadProfileScopedState();
await imageProvider.reloadProfileScopedState();
await appProvider.reloadProfileScopedSettings();
}

View File

@@ -0,0 +1,102 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
class RecentTcpConnection {
final String name;
final String host;
final int port;
final DateTime lastUsedAt;
const RecentTcpConnection({
required this.name,
required this.host,
required this.port,
required this.lastUsedAt,
});
Map<String, Object> toJson() => <String, Object>{
'name': name,
'host': host,
'port': port,
'lastUsedAt': lastUsedAt.toIso8601String(),
};
factory RecentTcpConnection.fromJson(Map<String, dynamic> json) {
return RecentTcpConnection(
name: (json['name'] as String?)?.trim().isNotEmpty == true
? (json['name'] as String).trim()
: ((json['host'] as String?) ?? '').trim(),
host: ((json['host'] as String?) ?? '').trim(),
port: (json['port'] as num?)?.toInt() ?? 0,
lastUsedAt:
DateTime.tryParse((json['lastUsedAt'] as String?) ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
);
}
}
class RecentTcpConnectionsService {
static const String _prefsKey = 'recent_tcp_connections_v1';
static const int _maxEntries = 5;
static Future<List<RecentTcpConnection>> load() async {
final prefs = await SharedPreferences.getInstance();
final storedEntries = prefs.getStringList(_prefsKey) ?? const <String>[];
final connections = <RecentTcpConnection>[];
for (final entry in storedEntries) {
try {
final decoded = jsonDecode(entry);
if (decoded is! Map<String, dynamic>) {
continue;
}
final connection = RecentTcpConnection.fromJson(decoded);
if (connection.host.isEmpty || connection.port <= 0) {
continue;
}
connections.add(connection);
} catch (_) {
continue;
}
}
connections.sort((a, b) => b.lastUsedAt.compareTo(a.lastUsedAt));
return connections.take(_maxEntries).toList(growable: false);
}
static Future<List<RecentTcpConnection>> remember({
required String name,
required String host,
required int port,
}) async {
final trimmedHost = host.trim();
if (trimmedHost.isEmpty || port <= 0) {
return load();
}
final normalizedName = name.trim().isNotEmpty ? name.trim() : trimmedHost;
final existing = await load();
final updated = <RecentTcpConnection>[
RecentTcpConnection(
name: normalizedName,
host: trimmedHost,
port: port,
lastUsedAt: DateTime.now(),
),
...existing.where(
(connection) =>
connection.host != trimmedHost || connection.port != port,
),
].take(_maxEntries).toList(growable: false);
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList(
_prefsKey,
updated
.map((connection) => jsonEncode(connection.toJson()))
.toList(growable: false),
);
return updated;
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'profiles_feature_service.dart';
/// Stores per-channel region scope settings.
///
/// Each channel can optionally have a region scope assigned. When set, the app
/// will set the flood scope on the device before sending channel messages so
/// that repeaters outside the region won't forward them.
class RegionScopePreferences {
static const String _namePrefix = 'region_scope_name_';
static const String _keyPrefix = 'region_scope_key_';
/// Get the saved scope for a channel.
/// Returns null if no scope is set.
static Future<({String name, Uint8List key})?> getScope(
int channelIdx,
) async {
final prefs = await SharedPreferences.getInstance();
final name = prefs.getString(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
);
final keyBase64 = prefs.getString(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
);
if (name == null || keyBase64 == null) return null;
try {
return (name: name, key: Uint8List.fromList(base64.decode(keyBase64)));
} catch (_) {
return null;
}
}
/// Save a region scope for a channel.
static Future<void> setScope(
int channelIdx,
String name,
Uint8List key,
) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
name,
);
await prefs.setString(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
base64.encode(key),
);
}
/// Clear the region scope for a channel.
static Future<void> clearScope(int channelIdx) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(
ProfileStorageScope.scopedKey('$_namePrefix$channelIdx'),
);
await prefs.remove(
ProfileStorageScope.scopedKey('$_keyPrefix$channelIdx'),
);
}
/// Clear all region scopes (all channels).
static Future<void> clearAllScopes() async {
final prefs = await SharedPreferences.getInstance();
final keys = prefs.getKeys();
for (final key in keys) {
if (key.contains(_namePrefix) || key.contains(_keyPrefix)) {
await prefs.remove(key);
}
}
}
/// Derive the 16-byte transport key for a region name.
///
/// Matches firmware `TransportKeyStore::getAutoKeyFor`:
/// `SHA256("#regionname")` → first 16 bytes.
/// The name must include the leading `#` (auto-prepended if missing).
static Uint8List deriveRegionKey(String regionName) {
final normalized =
regionName.startsWith('#') ? regionName : '#$regionName';
final digest = sha256.convert(utf8.encode(normalized));
return Uint8List.fromList(digest.bytes.sublist(0, 16));
}
}

View File

@@ -0,0 +1,50 @@
import 'package:meshcore_client/meshcore_client.dart';
import 'serial_transport_stub.dart'
if (dart.library.io) 'serial_transport_io.dart'
if (dart.library.js_interop) 'serial_transport_web.dart';
const int kSerialBaudRate = 115200;
class SerialDeviceInfo {
final String id;
final String title;
final String subtitle;
final Object handle;
const SerialDeviceInfo({
required this.id,
required this.title,
required this.subtitle,
required this.handle,
});
}
class SerialConnection {
final MeshCoreSerialService service;
final String deviceId;
final String deviceName;
final Future<void> Function() disconnect;
const SerialConnection({
required this.service,
required this.deviceId,
required this.deviceName,
required this.disconnect,
});
}
abstract class SerialTransport {
bool get isSupported;
bool get canRequestDevice;
String get actionLabel;
String get emptyStateTitle;
String get unsupportedMessage;
Future<List<SerialDeviceInfo>> listDevices();
Future<SerialDeviceInfo?> requestDevice();
Future<SerialConnection> connect(SerialDeviceInfo device);
void dispose();
}
SerialTransport createSerialTransport() => createSerialTransportImpl();

View File

@@ -0,0 +1,283 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter_libserialport/flutter_libserialport.dart';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:usb_serial/usb_serial.dart';
import 'serial_transport.dart';
class _IoSerialTransport implements SerialTransport {
@override
bool get isSupported =>
Platform.isAndroid || Platform.isMacOS || Platform.isWindows;
@override
bool get canRequestDevice => false;
@override
String get actionLabel =>
Platform.isAndroid ? 'Scan USB devices' : 'Scan serial ports';
@override
String get emptyStateTitle => Platform.isAndroid
? 'No USB serial devices found.\nConnect a MeshCore device via OTG cable.'
: 'No serial ports found.\nConnect a MeshCore device over USB serial.';
@override
String get unsupportedMessage =>
'Serial is supported on Android, macOS, Windows, and compatible web browsers.';
@override
Future<List<SerialDeviceInfo>> listDevices() async {
if (Platform.isAndroid) {
final devices = await UsbSerial.listDevices();
return devices.map(_androidDeviceInfo).toList(growable: false);
}
if (Platform.isMacOS || Platform.isWindows) {
return _desktopSerialDevices();
}
return const [];
}
@override
Future<SerialDeviceInfo?> requestDevice() async => null;
@override
Future<SerialConnection> connect(SerialDeviceInfo device) async {
if (Platform.isAndroid) {
return _connectAndroid(device);
}
if (Platform.isMacOS || Platform.isWindows) {
return _connectDesktop(device);
}
throw UnsupportedError(unsupportedMessage);
}
SerialDeviceInfo _androidDeviceInfo(UsbDevice device) {
final title = _firstNonEmpty([
device.productName,
device.manufacturerName,
'USB Serial Device',
]);
final subtitleParts = <String>[
if ((device.manufacturerName ?? '').trim().isNotEmpty)
device.manufacturerName!.trim(),
if (device.vid != null && device.pid != null)
'VID:${_hex(device.vid!)} PID:${_hex(device.pid!)}',
];
return SerialDeviceInfo(
id: '${device.deviceId}:${device.vid ?? 'na'}:${device.pid ?? 'na'}:${device.productName ?? 'usb'}',
title: title,
subtitle: subtitleParts.isEmpty
? 'Ready over OTG serial'
: subtitleParts.join(''),
handle: device,
);
}
Future<List<SerialDeviceInfo>> _desktopSerialDevices() async {
final ports = <SerialDeviceInfo>[];
for (final address in SerialPort.availablePorts) {
final port = SerialPort(address);
try {
final title = _firstNonEmpty([
port.productName,
port.description,
address,
]);
final subtitleParts = <String>[
address,
if ((port.manufacturer ?? '').trim().isNotEmpty)
port.manufacturer!.trim(),
if (port.vendorId != null && port.productId != null)
'VID:${_hex(port.vendorId!)} PID:${_hex(port.productId!)}',
];
ports.add(
SerialDeviceInfo(
id: address,
title: title,
subtitle: subtitleParts.join(''),
handle: address,
),
);
} finally {
port.dispose();
}
}
return ports;
}
Future<SerialConnection> _connectAndroid(SerialDeviceInfo device) async {
final usbDevice = device.handle as UsbDevice;
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
StreamSubscription<Uint8List>? inputSubscription;
UsbPort? port;
try {
port = await usbDevice.create();
if (port == null) {
throw Exception('Failed to create USB port');
}
final opened = await port.open();
if (!opened) {
throw Exception('Failed to open USB port');
}
await port.setDTR(true);
await port.setRTS(true);
await port.setPortParameters(
kSerialBaudRate,
UsbPort.DATABITS_8,
UsbPort.STOPBITS_1,
UsbPort.PARITY_NONE,
);
service.writeRaw = (data) async {
await port!.write(data);
};
inputSubscription = port.inputStream?.listen(
(data) => service.feedRawBytes(data),
onError: (error) {
debugPrint('❌ [Serial/Android] Read error: $error');
service.markDisconnected();
},
onDone: () {
debugPrint('⚠️ [Serial/Android] Port closed');
service.markDisconnected();
},
);
final connected = await service.markConnected();
if (!connected) {
throw Exception('Serial session initialization failed');
}
return SerialConnection(
service: service,
deviceId:
'${usbDevice.deviceId}:${usbDevice.vid ?? 'na'}:${usbDevice.pid ?? 'na'}',
deviceName: device.title,
disconnect: () async {
await inputSubscription?.cancel();
inputSubscription = null;
service.writeRaw = null;
await port?.close();
port = null;
},
);
} catch (_) {
await inputSubscription?.cancel();
service.writeRaw = null;
await port?.close();
service.dispose();
rethrow;
}
}
Future<SerialConnection> _connectDesktop(SerialDeviceInfo device) async {
final portName = device.handle as String;
final port = SerialPort(portName);
final config = SerialPortConfig();
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
SerialPortReader? reader;
StreamSubscription<Uint8List>? inputSubscription;
try {
if (!port.openReadWrite()) {
throw Exception(
SerialPort.lastError?.message ?? 'Failed to open serial port',
);
}
config.baudRate = kSerialBaudRate;
config.bits = 8;
config.stopBits = 1;
config.parity = SerialPortParity.none;
config.setFlowControl(SerialPortFlowControl.none);
config.dtr = SerialPortDtr.on;
config.rts = SerialPortRts.on;
port.config = config;
service.writeRaw = (data) async {
var offset = 0;
while (offset < data.length) {
final written = port.write(data.sublist(offset));
if (written <= 0) {
throw Exception(
SerialPort.lastError?.message ?? 'Failed to write to serial port',
);
}
offset += written;
}
};
reader = SerialPortReader(port);
inputSubscription = reader.stream.listen(
(data) => service.feedRawBytes(data),
onError: (error) {
debugPrint('❌ [Serial/Desktop] Read error: $error');
service.markDisconnected();
},
onDone: () {
debugPrint('⚠️ [Serial/Desktop] Reader closed');
service.markDisconnected();
},
);
final connected = await service.markConnected();
if (!connected) {
throw Exception('Serial session initialization failed');
}
return SerialConnection(
service: service,
deviceId: portName,
deviceName: device.title,
disconnect: () async {
await inputSubscription?.cancel();
inputSubscription = null;
reader?.close();
reader = null;
service.writeRaw = null;
if (port.isOpen) {
port.close();
}
port.dispose();
config.dispose();
},
);
} catch (_) {
await inputSubscription?.cancel();
reader?.close();
service.writeRaw = null;
if (port.isOpen) {
port.close();
}
port.dispose();
config.dispose();
service.dispose();
rethrow;
}
}
@override
void dispose() {}
}
String _firstNonEmpty(List<String?> values) {
for (final value in values) {
final trimmed = value?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
return trimmed;
}
}
return 'Serial Device';
}
String _hex(int value) => value.toRadixString(16).padLeft(4, '0');
SerialTransport createSerialTransportImpl() => _IoSerialTransport();

View File

@@ -0,0 +1,35 @@
import 'serial_transport.dart';
class _UnsupportedSerialTransport implements SerialTransport {
@override
bool get isSupported => false;
@override
bool get canRequestDevice => false;
@override
String get actionLabel => 'Serial unsupported';
@override
String get emptyStateTitle => 'Serial is unavailable on this platform.';
@override
String get unsupportedMessage =>
'Serial is supported on Android, macOS, Windows, and compatible web browsers.';
@override
Future<List<SerialDeviceInfo>> listDevices() async => const [];
@override
Future<SerialDeviceInfo?> requestDevice() async => null;
@override
Future<SerialConnection> connect(SerialDeviceInfo device) async {
throw UnsupportedError(unsupportedMessage);
}
@override
void dispose() {}
}
SerialTransport createSerialTransportImpl() => _UnsupportedSerialTransport();

View File

@@ -0,0 +1,209 @@
import 'dart:async';
import 'dart:js_interop';
import 'dart:js_interop_unsafe';
import 'package:meshcore_client/meshcore_client.dart';
import 'package:web/web.dart' as web;
import 'package:webserial/webserial.dart';
import 'serial_transport.dart';
class _WebSerialTransport implements SerialTransport {
@override
bool get isSupported =>
web.window.navigator.hasProperty('serial'.toJS).toDart;
@override
bool get canRequestDevice => true;
@override
String get actionLabel => 'Choose serial device';
@override
String get emptyStateTitle =>
'No serial devices granted yet.\nChoose a device and approve access in your browser.';
@override
String get unsupportedMessage =>
'Web Serial requires a compatible browser such as Chrome or Edge and a secure context (HTTPS or localhost).';
@override
Future<List<SerialDeviceInfo>> listDevices() async {
if (!isSupported) {
return const [];
}
final ports = await serial.getPorts().toDart;
return ports
.toDart
.whereType<JSSerialPort>()
.map(_deviceInfoForPort)
.toList(growable: false);
}
@override
Future<SerialDeviceInfo?> requestDevice() async {
if (!isSupported) {
return null;
}
final port = await requestWebSerialPort(null);
if (port == null) {
return null;
}
return _deviceInfoForPort(port);
}
@override
Future<SerialConnection> connect(SerialDeviceInfo device) async {
final port = device.handle as JSSerialPort;
final service = MeshCoreSerialService(appName: 'MeshCore SAR');
web.ReadableStreamDefaultReader? reader;
bool keepReading = false;
try {
await port
.open(
JSSerialOptions(
baudRate: kSerialBaudRate,
dataBits: 8,
stopBits: 1,
parity: 'none',
bufferSize: 1024,
flowControl: 'none',
),
)
.toDart;
await port
.setSignals(
JSSerialOutputSignals(
dataTerminalReady: true,
requestToSend: true,
),
)
.toDart;
service.writeRaw = (data) async {
final writable = port.writable;
if (writable == null) {
throw Exception('Serial port is not writable');
}
final writer =
writable.getWriter() as web.WritableStreamDefaultWriter?;
if (writer == null) {
throw Exception('Failed to acquire serial writer');
}
try {
await writer.write(data.toJS).toDart;
} finally {
writer.releaseLock();
}
};
final readable = port.readable;
if (readable == null) {
throw Exception('Serial port is not readable');
}
reader = readable.getReader() as web.ReadableStreamDefaultReader?;
if (reader == null) {
throw Exception('Failed to acquire serial reader');
}
keepReading = true;
unawaited(_readLoop(reader, service, () => keepReading));
final connected = await service.markConnected();
if (!connected) {
throw Exception('Serial session initialization failed');
}
return SerialConnection(
service: service,
deviceId: device.id,
deviceName: device.title,
disconnect: () async {
keepReading = false;
try {
await reader?.cancel().toDart;
} catch (_) {}
try {
reader?.releaseLock();
} catch (_) {}
reader = null;
service.writeRaw = null;
try {
await port.close().toDart;
} catch (_) {}
},
);
} catch (_) {
keepReading = false;
try {
await reader?.cancel().toDart;
} catch (_) {}
try {
reader?.releaseLock();
} catch (_) {}
service.writeRaw = null;
try {
await port.close().toDart;
} catch (_) {}
service.dispose();
rethrow;
}
}
Future<void> _readLoop(
web.ReadableStreamDefaultReader reader,
MeshCoreSerialService service,
bool Function() shouldContinue,
) async {
while (shouldContinue()) {
try {
final result = await reader.read().toDart;
if (result.done) {
break;
}
final value = result.value;
if (value == null) {
continue;
}
service.feedRawBytes((value as JSUint8Array).toDart);
} catch (_) {
break;
}
}
service.markDisconnected();
try {
reader.releaseLock();
} catch (_) {}
}
SerialDeviceInfo _deviceInfoForPort(JSSerialPort port) {
final info = port.getInfo();
final vendorId = info.usbVendorId;
final productId = info.usbProductId;
final title = (vendorId != 0 || productId != 0)
? 'USB ${_hex(vendorId)}:${_hex(productId)}'
: 'Granted serial device';
final subtitle = [
if (vendorId != 0 || productId != 0)
'VID:${_hex(vendorId)} PID:${_hex(productId)}',
'Web Serial',
].join('');
return SerialDeviceInfo(
id: '$title::$subtitle',
title: title,
subtitle: subtitle,
handle: port,
);
}
@override
void dispose() {}
}
String _hex(int value) => value.toRadixString(16).padLeft(4, '0');
SerialTransport createSerialTransportImpl() => _WebSerialTransport();

View File

@@ -0,0 +1,332 @@
import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'offline_tile_cache_service.dart';
import 'tile_math_service.dart';
/// Events emitted during tile download.
sealed class TileDownloadEvent {}
class TileDownloadStarted extends TileDownloadEvent {
final int totalTiles;
TileDownloadStarted(this.totalTiles);
}
class TileDownloaded extends TileDownloadEvent {
final double north, south, east, west;
TileDownloaded({
required this.north,
required this.south,
required this.east,
required this.west,
});
}
class TileSkipped extends TileDownloadEvent {
final double north, south, east, west;
TileSkipped({
required this.north,
required this.south,
required this.east,
required this.west,
});
}
class TileFailed extends TileDownloadEvent {
final TileCoord coord;
final String error;
TileFailed(this.coord, this.error);
}
class TileDownloadComplete extends TileDownloadEvent {
final int downloaded;
final int skipped;
final int failed;
final int total;
TileDownloadComplete({
required this.downloaded,
required this.skipped,
required this.failed,
required this.total,
});
}
class TileBatchSkipped extends TileDownloadEvent {
final int count;
final int total;
TileBatchSkipped({required this.count, required this.total});
}
class TileDownloadCancelled extends TileDownloadEvent {}
/// Downloads map tiles for given polygons and zoom levels.
class TileDownloadService {
final OfflineTileCacheService _cache = OfflineTileCacheService.instance;
final http.Client _httpClient = http.Client();
bool _cancelled = false;
/// Cancel an ongoing download.
void cancel() {
_cancelled = true;
}
/// Download tiles for the given polygons and zoom range.
///
/// Returns a stream of [TileDownloadEvent]s.
/// [urlTemplate] should contain `{z}`, `{x}`, `{y}` placeholders,
/// and optionally `{s}` for subdomains.
Stream<TileDownloadEvent> downloadTiles({
required List<List<LatLng>> polygons,
required int minZoom,
required int maxZoom,
required String urlTemplate,
String? displayName,
int maxConcurrency = 6,
int rateLimit = 30,
}) {
final controller = StreamController<TileDownloadEvent>();
_runDownload(
controller: controller,
polygons: polygons,
minZoom: minZoom,
maxZoom: maxZoom,
urlTemplate: urlTemplate,
displayName: displayName,
maxConcurrency: maxConcurrency,
rateLimit: rateLimit,
);
return controller.stream;
}
Future<void> _runDownload({
required StreamController<TileDownloadEvent> controller,
required List<List<LatLng>> polygons,
required int minZoom,
required int maxZoom,
required String urlTemplate,
String? displayName,
required int maxConcurrency,
required int rateLimit,
}) async {
_cancelled = false;
final styleHash = _cache.styleHashFromUrl(urlTemplate);
// Save style metadata with download region so it can be reused
final region = DownloadRegion(
polygons: polygons
.map((poly) =>
poly.map((p) => [p.latitude, p.longitude]).toList())
.toList(),
minZoom: minZoom,
maxZoom: maxZoom,
);
await _cache.saveStyleMeta(
styleHash,
displayName: displayName ?? urlTemplate,
urlTemplate: urlTemplate,
region: region,
);
final allTiles =
TileMathService.getTilesForPolygons(polygons, minZoom, maxZoom);
// Load manifest once and partition tiles into needed vs already cached
final manifest = await _cache.loadManifest(styleHash);
final tilesToDownload = <TileCoord>[];
var skipped = 0;
for (final tile in allTiles) {
final key = '${tile.z}/${tile.x}/${tile.y}';
if (manifest.contains(key)) {
skipped++;
} else {
tilesToDownload.add(tile);
}
}
final total = allTiles.length;
controller.add(TileDownloadStarted(total));
// Report all skipped tiles immediately (no per-tile filesystem check)
if (skipped > 0) {
controller.add(TileBatchSkipped(count: skipped, total: total));
}
if (tilesToDownload.isEmpty) {
controller.add(TileDownloadComplete(
downloaded: 0, skipped: skipped, failed: 0, total: total));
await controller.close();
return;
}
var downloaded = 0;
var failed = 0;
final semaphore = _Semaphore(maxConcurrency);
final rateLimiter = _RateLimiter(rateLimit);
final futures = <Future<void>>[];
for (final tile in tilesToDownload) {
if (_cancelled) break;
await rateLimiter.wait();
if (_cancelled) break;
await semaphore.acquire();
if (_cancelled) {
semaphore.release();
break;
}
final future = _downloadSingleTile(tile, urlTemplate, styleHash)
.then((event) {
if (!controller.isClosed) {
controller.add(event);
if (event is TileDownloaded) {
downloaded++;
} else if (event is TileFailed) {
failed++;
}
}
semaphore.release();
});
futures.add(future);
}
// Wait for all in-flight downloads to finish
await Future.wait(futures);
if (_cancelled) {
controller.add(TileDownloadCancelled());
} else {
controller.add(TileDownloadComplete(
downloaded: downloaded,
skipped: skipped,
failed: failed,
total: total,
));
}
await controller.close();
}
Future<TileDownloadEvent> _downloadSingleTile(
TileCoord tile,
String urlTemplate,
String styleHash,
) async {
final bounds = TileMathService.tileBounds(tile.x, tile.y, tile.z);
// Build URL
final subdomains = ['a', 'b', 'c'];
var url = urlTemplate
.replaceAll('{s}', subdomains[tile.x % 3])
.replaceAll('{z}', '${tile.z}')
.replaceAll('{x}', '${tile.x}')
.replaceAll('{y}', '${tile.y}');
// Download with retries
const maxRetries = 3;
for (var attempt = 0; attempt < maxRetries; attempt++) {
if (_cancelled) {
return TileFailed(tile, 'Cancelled');
}
try {
final response = await _httpClient.get(
Uri.parse(url),
headers: {'User-Agent': 'MeshCoreSAR/1.0'},
);
if (response.statusCode != 200) {
if (attempt < maxRetries - 1) {
await Future.delayed(Duration(seconds: 1 << attempt));
continue;
}
return TileFailed(tile, 'HTTP ${response.statusCode}');
}
// Store tile (PNG → AVIF conversion happens inside cache service)
await _cache.putTile(
styleHash, tile.z, tile.x, tile.y, response.bodyBytes);
return TileDownloaded(
north: bounds.north,
south: bounds.south,
east: bounds.east,
west: bounds.west,
);
} catch (e) {
if (attempt < maxRetries - 1) {
await Future.delayed(Duration(seconds: 1 << attempt));
continue;
}
return TileFailed(tile, e.toString());
}
}
return TileFailed(tile, 'Max retries exceeded');
}
void dispose() {
_cancelled = true;
_httpClient.close();
}
}
/// Simple counting semaphore for concurrency limiting.
class _Semaphore {
final int maxCount;
int _currentCount = 0;
final _waitQueue = <Completer<void>>[];
_Semaphore(this.maxCount);
Future<void> acquire() async {
if (_currentCount < maxCount) {
_currentCount++;
return;
}
final completer = Completer<void>();
_waitQueue.add(completer);
await completer.future;
}
void release() {
if (_waitQueue.isNotEmpty) {
_waitQueue.removeAt(0).complete();
} else {
_currentCount--;
}
}
}
/// Rate limiter that ensures no more than [maxPerSecond] operations per second.
class _RateLimiter {
final int maxPerSecond;
final _timestamps = <DateTime>[];
_RateLimiter(this.maxPerSecond);
Future<void> wait() async {
final now = DateTime.now();
_timestamps
.removeWhere((t) => now.difference(t) > const Duration(seconds: 1));
if (_timestamps.length >= maxPerSecond) {
final oldest = _timestamps.first;
final waitTime = const Duration(seconds: 1) - now.difference(oldest);
if (waitTime > Duration.zero) {
await Future.delayed(waitTime);
}
_timestamps.removeAt(0);
}
_timestamps.add(DateTime.now());
}
}

View File

@@ -0,0 +1,268 @@
import 'dart:math';
import 'package:latlong2/latlong.dart';
/// A tile coordinate with x, y, and zoom level.
class TileCoord {
final int x;
final int y;
final int z;
const TileCoord(this.x, this.y, this.z);
@override
bool operator ==(Object other) =>
other is TileCoord && other.x == x && other.y == y && other.z == z;
@override
int get hashCode => Object.hash(x, y, z);
@override
String toString() => 'TileCoord($z/$x/$y)';
}
/// A geographical bounding box.
class TileBounds {
final double north;
final double south;
final double east;
final double west;
const TileBounds({
required this.north,
required this.south,
required this.east,
required this.west,
});
}
/// Pure math utilities for slippy map tile calculations.
///
/// Ported from the Go offline-map-tile-downloader.
class TileMathService {
const TileMathService._();
/// Convert latitude/longitude to tile coordinates at the given zoom level.
static (int x, int y) latLonToTile(double lat, double lon, int zoom) {
final latRad = lat * pi / 180;
final n = pow(2, zoom).toDouble();
final x = (n * ((lon + 180) / 360)).floor();
final y =
(n * (1 - (log(tan(latRad) + 1 / cos(latRad)) / pi)) / 2).floor();
return (x, y);
}
/// Calculate the geographical bounding box of a tile.
static TileBounds tileBounds(int x, int y, int z) {
final n = pow(2.0, z).toDouble();
final lonDeg = x / n * 360.0 - 180.0;
final latRad = atan(sinh(pi * (1 - 2 * y / n)));
final latDeg = latRad * 180.0 / pi;
final lon2Deg = (x + 1) / n * 360.0 - 180.0;
final lat2Rad = atan(sinh(pi * (1 - 2 * (y + 1) / n)));
final lat2Deg = lat2Rad * 180.0 / pi;
return TileBounds(
north: latDeg,
south: lat2Deg,
east: lon2Deg,
west: lonDeg,
);
}
/// Hyperbolic sine.
static double sinh(double x) => (exp(x) - exp(-x)) / 2;
/// Check if a point is inside a polygon using the ray casting algorithm.
static bool polygonContains(List<LatLng> poly, LatLng point) {
var inside = false;
for (int i = 0, j = poly.length - 1; i < poly.length; j = i++) {
if ((poly[i].latitude > point.latitude) !=
(poly[j].latitude > point.latitude) &&
(point.longitude <
(poly[j].longitude - poly[i].longitude) *
(point.latitude - poly[i].latitude) /
(poly[j].latitude - poly[i].latitude) +
poly[i].longitude)) {
inside = !inside;
}
}
return inside;
}
/// Check if a bounding box contains a point.
static bool boundsContains(TileBounds bounds, LatLng point) {
return point.latitude <= bounds.north &&
point.latitude >= bounds.south &&
point.longitude >= bounds.west &&
point.longitude <= bounds.east;
}
/// Check if a polygon intersects with a tile bounding box.
static bool polygonIntersects(List<LatLng> poly, TileBounds bounds) {
// Check if any polygon vertex is inside the tile
for (final p in poly) {
if (boundsContains(bounds, p)) return true;
}
// Check if any tile corner is inside the polygon
final corners = [
LatLng(bounds.north, bounds.west),
LatLng(bounds.north, bounds.east),
LatLng(bounds.south, bounds.west),
LatLng(bounds.south, bounds.east),
];
for (final corner in corners) {
if (polygonContains(poly, corner)) return true;
}
// Check if any polygon edge intersects any tile edge
final tileEdges = [
(corners[0], corners[1]),
(corners[1], corners[3]),
(corners[3], corners[2]),
(corners[2], corners[0]),
];
for (int i = 0; i < poly.length; i++) {
final p1 = poly[i];
final p2 = poly[(i + 1) % poly.length];
for (final edge in tileEdges) {
if (_lineIntersects(p1, p2, edge.$1, edge.$2)) return true;
}
}
return false;
}
/// Check if two line segments intersect.
static bool _lineIntersects(LatLng p1, LatLng q1, LatLng p2, LatLng q2) {
final o1 = _orientation(p1, q1, p2);
final o2 = _orientation(p1, q1, q2);
final o3 = _orientation(p2, q2, p1);
final o4 = _orientation(p2, q2, q1);
if (o1 != o2 && o3 != o4) return true;
if (o1 == 0 && _onSegment(p1, p2, q1)) return true;
if (o2 == 0 && _onSegment(p1, q2, q1)) return true;
if (o3 == 0 && _onSegment(p2, p1, q2)) return true;
if (o4 == 0 && _onSegment(p2, q1, q2)) return true;
return false;
}
/// Find orientation of ordered triplet (p, q, r).
/// Returns 0 for collinear, 1 for clockwise, 2 for counterclockwise.
static int _orientation(LatLng p, LatLng q, LatLng r) {
final val = (q.longitude - p.longitude) * (r.latitude - q.latitude) -
(q.latitude - p.latitude) * (r.longitude - q.longitude);
if (val == 0) return 0;
return val > 0 ? 1 : 2;
}
/// Check if point q lies on segment pr.
static bool _onSegment(LatLng p, LatLng q, LatLng r) {
return q.latitude <= max(p.latitude, r.latitude) &&
q.latitude >= min(p.latitude, r.latitude) &&
q.longitude <= max(p.longitude, r.longitude) &&
q.longitude >= min(p.longitude, r.longitude);
}
/// Get all tiles that overlap with the given polygons across zoom levels.
static List<TileCoord> getTilesForPolygons(
List<List<LatLng>> polygons,
int minZoom,
int maxZoom,
) {
final tileSet = <TileCoord>{};
for (final poly in polygons) {
if (poly.length < 3) continue;
// Find bounding box of polygon
var minLat = 90.0, minLon = 180.0;
var maxLat = -90.0, maxLon = -180.0;
for (final p in poly) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLon) minLon = p.longitude;
if (p.longitude > maxLon) maxLon = p.longitude;
}
for (int z = minZoom; z <= maxZoom; z++) {
final (tlx, tly) = latLonToTile(maxLat, minLon, z);
final (brx, bry) = latLonToTile(minLat, maxLon, z);
for (int x = tlx; x <= brx; x++) {
for (int y = tly; y <= bry; y++) {
final tile = TileCoord(x, y, z);
if (tileSet.contains(tile)) continue;
final bounds = tileBounds(x, y, z);
// Check if all tile corners are inside the polygon
final allCornersInside = polygonContains(
poly, LatLng(bounds.north, bounds.west)) &&
polygonContains(poly, LatLng(bounds.north, bounds.east)) &&
polygonContains(poly, LatLng(bounds.south, bounds.west)) &&
polygonContains(poly, LatLng(bounds.south, bounds.east));
if (allCornersInside) {
tileSet.add(tile);
continue;
}
// Check if all polygon vertices are inside the tile
var polyInTile = true;
for (final p in poly) {
if (!boundsContains(bounds, p)) {
polyInTile = false;
break;
}
}
if (polyInTile) {
tileSet.add(tile);
continue;
}
// Check for intersection
if (polygonIntersects(poly, bounds)) {
tileSet.add(tile);
}
}
}
}
}
return tileSet.toList();
}
/// Estimate the number of tiles for given polygons and zoom range.
/// Faster than getTilesForPolygons — uses bounding box approximation.
static int estimateTileCount(
List<List<LatLng>> polygons,
int minZoom,
int maxZoom,
) {
var count = 0;
for (final poly in polygons) {
if (poly.length < 3) continue;
var minLat = 90.0, minLon = 180.0;
var maxLat = -90.0, maxLon = -180.0;
for (final p in poly) {
if (p.latitude < minLat) minLat = p.latitude;
if (p.latitude > maxLat) maxLat = p.latitude;
if (p.longitude < minLon) minLon = p.longitude;
if (p.longitude > maxLon) maxLon = p.longitude;
}
for (int z = minZoom; z <= maxZoom; z++) {
final (tlx, tly) = latLonToTile(maxLat, minLon, z);
final (brx, bry) = latLonToTile(minLat, maxLon, z);
count += (brx - tlx + 1) * (bry - tly + 1);
}
}
return count;
}
}

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